From 67600adba43318ff5de3f38875e19e1d4745c234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 31 Aug 2026 02:03:57 +0900 Subject: [PATCH 01/15] =?UTF-8?q?fix(afternote):=20=EC=88=98=EC=A0=95=20PA?= =?UTF-8?q?TCH=20=EA=B0=80=20=EC=95=88=20=EA=B1=B4=EB=93=9C=EB=A6=B0=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=A5=BC=20=EC=8B=A3=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4=20(#1617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수정 요청이 폼 전체 스냅샷을 통째로 보내던 것을, 프리필 원본과 비교해 실제로 달라진 top-level 필드만 싣도록 좁힌다. 에디터를 연 뒤 서버가 바뀌면 사용자가 만진 적도 없는 필드가 낡은 로컬 값으로 덮였다 — 곡을 건드린 적 없는 「제목만 수정」이 그 사이 다른 기기가 추가한 곡을 전부 지우는 lost update 다. 서버는 「키 없음 = 기존 값 유지」로 읽으므로, 안 건드린 필드를 아예 빼는 것으로 그 경로를 구조적으로 닫는다. - ViewModel 이 상세 응답을 pristine baseline 으로 보존한다 (폼 변경을 따라가지 않는다 — 따라가면 비교할 대상이 사라진다). - FormMapper 가 baseline 과 현재 폼의 의미값을 견줘 달라진 필드만 채운다. 공백·빈 문자열·뜻 없는 정렬은 변경으로 세지 않는다. - payload·DTO 가 「안 건드림(키 생략)」과 「전부 삭제(빈 배열)」를 가른다. #1596·#1599 가 세운 삭제 계약과 맞물린다. 기준이 없으면(상세 로드 실패) 종전처럼 전량을 싣는다 — 덜 보내다 사용자의 편집을 잃느니 낫다. Refs #1617 --- .../afternote/data/dto/AfternoteDto.kt | 19 +- .../data/mapper/AfternoteRequestMapper.kt | 7 +- .../data/mapper/LeaveMessageBlockMapper.kt | 11 +- .../AfternoteUpdatePartialPatchWireTest.kt | 165 +++++++++++ .../model/author/AfternoteUpdatePayload.kt | 32 +- .../domain/testing/FakeAfternoteRepository.kt | 10 +- .../editor/AfternoteEditorFormMapper.kt | 188 ++++++++++-- .../author/editor/AfternoteEditorViewModel.kt | 14 + .../author/AfternoteAuthorExtendedTest.kt | 20 +- .../AfternoteEditorPartialUpdateTest.kt | 278 ++++++++++++++++++ .../editor/EditorLeaveMessageBlocksTest.kt | 6 + 11 files changed, 710 insertions(+), 40 deletions(-) create mode 100644 feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt create mode 100644 feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorPartialUpdateTest.kt 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 24c519371f..f87002c5c0 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 @@ -36,10 +36,27 @@ data class AfternoteCreateAccountRequestDto( @SerialName("receivers") val receivers: List = emptyList(), ) +/** + * 애프터노트 수정(PATCH) 요청 바디 — **부분 갱신이다.** + * + * `null` 인 슬롯은 `encodeDefaults = false` 덕분에 **키째 빠지고**, 서버는 그것을 「기존 값 유지」로 + * 읽는다 (Afternote-BE `bbff47c` · BE#200·#201). 그러므로 여기서 기본값 `null` 은 편의가 아니라 + * 계약이다 — 사용자가 만지지 않은 필드를 이 DTO 에 채우면 그 순간 낡은 값이 남의 변경을 덮는다 + * (#1617). + * + * [title] 에도 기본값을 둔다. 제목은 늘 폼에 떠 있어 「안 바꿨다」를 말할 수단이 필요하다 — + * 서버도 `title` 생략을 계약으로 못박아 뒀다(`updateAfternote_OmitCategoryAndTitle_Success`). + * + * [category] 만 늘 실린다. 값을 바꾸는 필드가 아니라 **대상 확인용 단언**이라 lost update 축에 + * 참여하지 않는다 — 서버는 저장값과 다르면 400 을 내고 같으면 아무것도 바꾸지 않는다. + * + * 빈 컬렉션은 생략이 아니라 **삭제**다. `[]` 를 실으면 서버가 관계를 통째로 갈아 끼운다 + * (`actions`·`leaveMessage`·`receivers`, 그리고 `playlist.songs` — #1599). + */ @Serializable data class AfternoteUpdateRequestDto( @SerialName("category") val category: String, - @SerialName("title") val title: String, + @SerialName("title") val title: String? = null, @SerialName("actions") val processingMethods: List? = null, @SerialName("leaveMessage") val leaveMessage: List? = null, @SerialName("credentials") val credentials: AfternoteCredentialsDto? = null, diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt index 54a10027e5..6d4555a2ee 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt @@ -22,14 +22,17 @@ import com.afternote.feature.afternote.domain.model.author.ReceiverRefPayload /** * 도메인 → 요청 wire. 요청 바디 루트는 `toRequest`, 그 안에 실리는 부분 DTO 는 `toDto` 다. + * + * 수정 페이로드는 **슬롯을 하나도 접지 않고 그대로 옮긴다** (#1617). 어떤 필드를 실을지는 이미 + * `AfternoteEditorFormMapper` 가 프리필 원본과 비교해 정했다 — 여기서 `ifEmpty { null }` 류로 한 번 + * 더 손대면 「전부 삭제」가 「안 건드림」으로 조용히 바뀐다. */ - fun AfternoteUpdatePayload.toRequest() = AfternoteUpdateRequestDto( category = type.toAuthoringServerCategory(), title = title, processingMethods = processingMethods, - leaveMessage = leaveMessageBlocks.toDto(), + leaveMessage = leaveMessageBlocks.toPatchDto(), credentials = credentials?.toDto(), receivers = receivers?.map { it.toDto() }, memorial = memorial?.toDto(), diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockMapper.kt index 880b717720..71c6977050 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockMapper.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockMapper.kt @@ -14,7 +14,16 @@ fun List?.toLeaveMessageBlocks(): List LeaveMessageBlock(title = dto.title, body = body) }.orEmpty() -/** 도메인 → 요청. 남길 말씀이 없으면 빈 배열 대신 필드 자체를 빼도록 null 로 접는다. */ +/** 도메인 → 생성 요청. 남길 말씀이 없으면 빈 배열 대신 필드 자체를 빼도록 null 로 접는다. */ fun List.toDto(): List? = map { LeaveMessageBlockDto(title = it.title, body = it.body) } .takeIf { it.isNotEmpty() } + +/** + * 도메인 → 수정 요청. **[toDto] 처럼 빈 배열을 null 로 접지 않는다** (#1617). + * + * 수정은 부분 갱신이라 `null`(안 건드림 → 키 생략)과 `[]`(전부 삭제)가 서로 다른 지시다. + * 접어 버리면 마지막 말씀을 전부 지운 저장이 「유지」로 흡수돼 서버에 반영되지 않는다. + */ +fun List?.toPatchDto(): List? = + this?.map { LeaveMessageBlockDto(title = it.title, body = it.body) } diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt new file mode 100644 index 0000000000..3694ed2cd5 --- /dev/null +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt @@ -0,0 +1,165 @@ +package com.afternote.feature.afternote.data.dto + +import com.afternote.core.network.di.NetworkModule +import com.afternote.feature.afternote.data.mapper.toRequest +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.LeaveMessageBlock +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.MemorialWritePayload +import com.afternote.feature.afternote.domain.model.author.ReceiverRefPayload +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 수정 요청 바디의 **키 유무**를 고정한다 (#1617). + * + * 서버는 「키 없음 = 기존 값 유지」로 읽으므로(Afternote-BE `bbff47c` · BE#200·#201), 사용자가 만지지 + * 않은 필드를 빼는 것이 곧 lost update 방지다. 어느 필드를 뺄지는 `AfternoteEditorFormMapper` 가 + * 정하고, **정말로 wire 에서 빠지는지**는 여기서 못박는다. + * + * [AfternotePlaylistRequestWireTest] 와 같은 이유로 테스트용 Json 을 새로 만들지 않고 앱이 실제로 + * 쓰는 [NetworkModule.provideJson] 을 그대로 태운다 — 이 계약은 값이 아니라 *직렬화 설정* 위에 + * 서 있어서, 누가 `encodeDefaults` 를 켜면 여기서 깨져야 한다. + */ +class AfternoteUpdatePartialPatchWireTest { + private val json = NetworkModule.provideJson() + + private fun body(payload: AfternoteUpdatePayload) = json.encodeToJsonElement(payload.toRequest()).jsonObject + + /** + * 이슈 #1617 이 든 재현 경로의 반대편이다 — 제목만 고친 저장은 나머지를 **말하지 않는다.** + * 여기 어느 키 하나라도 되살아나면, 에디터를 연 뒤 서버가 바뀐 경우 그 필드가 낡은 값으로 덮인다. + */ + @Test + fun `제목만 실은 수정은 나머지 키가 바디에 아예 없다`() { + val patch = body(AfternoteUpdatePayload(type = AfternoteType.SOCIAL_NETWORK, title = "새 제목")) + + assertEquals("새 제목", patch.getValue("title").jsonPrimitive.content) + assertFalse("playlist" in patch) + assertFalse("receivers" in patch) + assertFalse("actions" in patch) + assertFalse("leaveMessage" in patch) + assertFalse("credentials" in patch) + } + + /** 제목조차 안 건드렸으면 제목도 빠진다 — 서버가 `title` 생략을 계약으로 못박아 뒀다. */ + @Test + fun `제목을 안 건드리면 title 키도 빠진다`() { + val patch = body(AfternoteUpdatePayload(type = AfternoteType.SOCIAL_NETWORK)) + + assertFalse("title" in patch) + } + + /** + * 카테고리만 늘 나간다 — 값을 바꾸는 필드가 아니라 **대상 확인용 단언**이다. + * 서버는 저장값과 다르면 400 을 내고, 같으면 아무것도 바꾸지 않는다. + */ + @Test + fun `카테고리는 아무것도 안 고쳐도 늘 실린다`() { + val patch = body(AfternoteUpdatePayload(type = AfternoteType.SOCIAL_NETWORK)) + + assertEquals("SOCIAL", patch.getValue("category").jsonPrimitive.content) + } + + /** 「전부 삭제」는 「안 건드림」과 다른 지시다 — 빈 배열이 키를 달고 나가야 서버가 지운다. */ + @Test + fun `빈 목록은 생략되지 않고 빈 배열로 실린다`() { + val patch = + body( + AfternoteUpdatePayload( + type = AfternoteType.SOCIAL_NETWORK, + processingMethods = emptyList(), + leaveMessageBlocks = emptyList(), + receivers = emptyList(), + ), + ) + + assertTrue("actions" in patch) + assertTrue(patch.getValue("actions").jsonArray.isEmpty()) + assertTrue("leaveMessage" in patch) + assertTrue(patch.getValue("leaveMessage").jsonArray.isEmpty()) + assertTrue("receivers" in patch) + assertTrue(patch.getValue("receivers").jsonArray.isEmpty()) + } + + @Test + fun `값을 실은 필드는 종전과 같은 키로 그대로 나간다`() { + val patch = + body( + AfternoteUpdatePayload( + type = AfternoteType.BUSINESS, + title = "제목", + processingMethods = listOf("계정 삭제"), + leaveMessageBlocks = listOf(LeaveMessageBlock(title = "가족에게", body = "고마웠어")), + credentials = AfternoteAccountCredentials(id = "account", password = "pw"), + receivers = listOf(ReceiverRefPayload(receiverId = 11L)), + ), + ) + + assertEquals("BUSINESS", patch.getValue("category").jsonPrimitive.content) + assertEquals( + "계정 삭제", + patch + .getValue("actions") + .jsonArray + .single() + .jsonPrimitive.content, + ) + assertEquals( + "고마웠어", + patch + .getValue("leaveMessage") + .jsonArray + .single() + .jsonObject + .getValue("body") + .jsonPrimitive.content, + ) + assertEquals( + "account", + patch + .getValue("credentials") + .jsonObject + .getValue("id") + .jsonPrimitive.content, + ) + assertEquals( + 11L, + patch + .getValue("receivers") + .jsonArray + .single() + .jsonObject + .getValue("receiverId") + .jsonPrimitive.content + .toLong(), + ) + } + + /** + * 추억 노트의 미디어 삭제는 **명시적 null** 이라 [MemorialWritePayload] 를 실을 때만 나간다 (#1596). + * 「안 건드림」은 그 바깥 — `playlist` 키 자체가 빠지는 것으로 말한다. + */ + @Test + fun `플레이리스트를 안 건드리면 미디어 삭제 지시도 나가지 않는다`() { + val untouched = body(AfternoteUpdatePayload(type = AfternoteType.MEMORIAL, title = "새 제목")) + val emptied = + body( + AfternoteUpdatePayload( + type = AfternoteType.MEMORIAL, + memorial = MemorialWritePayload(memorialPhotoUrl = null, songs = emptyList(), memorialVideo = null), + ), + ) + + assertFalse("playlist" in untouched) + assertTrue("playlist" in emptied) + assertTrue("memorialPhotoUrl" in emptied.getValue("playlist").jsonObject) + } +} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AfternoteUpdatePayload.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AfternoteUpdatePayload.kt index be2144fcd7..24cdc34b7e 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AfternoteUpdatePayload.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AfternoteUpdatePayload.kt @@ -3,11 +3,39 @@ package com.afternote.feature.afternote.domain.model.author import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock +/** + * 애프터노트 수정(PATCH) 페이로드 — **부분 갱신이다.** + * + * 서버는 수정 요청을 「받은 키만 반영」으로 읽는다: `AfternoteService.updateAfternote` 가 필드마다 + * `writeRequest.getX() != null` 을 보고 null 이면 기존 엔티티 값을 그대로 남긴다 + * (Afternote-BE `bbff47c` · BE#200·#201). 그래서 **필드를 비워 두는 것이 곧 「이 필드는 건드리지 + * 않았다」는 뜻**이고, 값을 채우는 것이 곧 「이 값으로 갈아 끼워라」다. + * + * 세 상태를 구분한다 (#1617): + * + * | 표현 | 뜻 | wire | + * |---|---|---| + * | `null` | 사용자가 만지지 않았다 | 키 자체가 나가지 않는다 | + * | 빈 컬렉션 | 전부 지워 달라 | `[]` 가 나간다 | + * | 값 | 이 값으로 바꿔 달라 | 값이 나간다 | + * + * **`null` 을 「빈 값」의 동의어로 쓰면 안 된다.** 폼이 들고 있는 전체 스냅샷을 매번 통째로 실으면, + * 에디터를 연 뒤 서버가 바뀐 경우 사용자가 만진 적 없는 필드까지 낡은 값으로 덮어쓴다 — 곡을 건드린 + * 적 없는 저장이 남이 추가한 곡을 전부 지우는 lost update 다 (#1617). 그래서 페이로드를 조립하는 + * `AfternoteEditorFormMapper` 는 **프리필 원본(baseline)과 비교해 실제로 달라진 필드만** 채운다. + * + * [type] 만 늘 채운다 — 값을 바꾸는 필드가 아니라 **어느 애프터노트를 고치는지 확인하는 단언**이다. + * 서버는 저장된 카테고리와 다르면 400 을 내고(`CATEGORY_CANNOT_BE_CHANGED`), 같으면 아무것도 + * 바꾸지 않는다. 카테고리는 수정 화면에서 애초에 바꿀 수 없다. + * + * 미디어만 규칙이 다르다 — [MemorialWritePayload] 안에서는 빈 배열이 아니라 **명시적 JSON null** 이 + * 삭제다 (#1596). 그쪽 계약은 해당 DTO KDoc 에 적혀 있다. + */ data class AfternoteUpdatePayload( val type: AfternoteType, - val title: String, + val title: String? = null, val processingMethods: List? = null, - val leaveMessageBlocks: List = emptyList(), + val leaveMessageBlocks: List? = null, val credentials: AfternoteAccountCredentials? = null, val receivers: List? = null, val memorial: MemorialWritePayload? = null, diff --git a/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt b/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt index 0f36b2e065..f9783ffb2a 100644 --- a/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt +++ b/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt @@ -155,9 +155,13 @@ class FakeAfternoteRepository( } } +/** + * 수정은 **부분 갱신**이라 `null` 슬롯은 「안 건드림」이다 — 서버와 같이 기존 값을 남긴다 (#1617). + * 여기서 덮어써 버리면 페이로드가 필드를 뺐는지 여부를 테스트가 구분하지 못한다. + */ private fun ListItem.updatedWith(payload: AfternoteUpdatePayload): ListItem = copy( - serviceName = payload.title, + serviceName = payload.title ?: serviceName, type = payload.type, account = account.updatedWith(payload.credentials), ) @@ -174,13 +178,13 @@ private fun Account.updatedWith(credentials: AfternoteAccountCredentials?): Acco private fun Detail.updatedWith(payload: AfternoteUpdatePayload): Detail = copy( - serviceName = payload.title, + serviceName = payload.title ?: serviceName, receivers = payload.receivers?.map { ref -> receivers.firstOrNull { it.receiverId == ref.receiverId } ?: DetailReceiver(receiverId = ref.receiverId, name = "", relation = "") } ?: receivers, - leaveMessageBlocks = payload.leaveMessageBlocks, + leaveMessageBlocks = payload.leaveMessageBlocks ?: leaveMessageBlocks, content = content.updatedWith(payload), ) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt index 4c79b9763d..90f329c652 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt @@ -219,38 +219,123 @@ internal object AfternoteEditorFormMapper { receiverIds = selectedReceiverIds, ) + /** + * 수정 요청 페이로드를 조립한다 — **[baseline] 과 달라진 필드만 싣는다** (#1617). + * + * 폼 전체 스냅샷을 매번 통째로 보내면, 에디터를 연 뒤 서버가 바뀐 경우 사용자가 만진 적도 없는 + * 필드가 낡은 값으로 덮인다. 서버는 「키 없음 = 유지」로 읽으므로, 안 건드린 필드를 아예 빼는 + * 것으로 그 사고를 구조적으로 없앤다. + * + * @param baseline 수정 진입 시 받은 상세 응답을 [buildUpdateBaseline] 로 옮긴 **원본 스냅샷**. + * `null`(상세를 아직/끝내 못 받음)이면 비교할 기준이 없으므로 종전처럼 전량을 싣는다 — + * 덜 보내다 사용자의 편집을 잃는 것보다 낫다. + */ fun buildUpdatePayload( type: AfternoteType, payload: RegisterAfternotePayload, selectedReceiverIds: List, playlistSongs: List, memorialMedia: MemorialMediaUrls, - ): AfternoteUpdatePayload = - when (type) { - AfternoteType.MEMORIAL -> { - AfternoteUpdatePayload( - type = AfternoteType.MEMORIAL, - title = payload.serviceName, - leaveMessageBlocks = payload.messageBlocks.toLeaveMessageBlocks(), - memorial = - buildMemorialWritePayload( - playlistSongs = playlistSongs, - memorialPhotoUrl = memorialMedia.memorialPhotoUrl, - memorialVideoUrl = memorialMedia.memorialVideoUrl, - memorialThumbnailUrl = memorialMedia.memorialThumbnailUrl, - ), - ) - } + baseline: AfternoteUpdatePayload?, + ): AfternoteUpdatePayload { + val full = + when (type) { + AfternoteType.MEMORIAL -> { + AfternoteUpdatePayload( + type = AfternoteType.MEMORIAL, + title = payload.serviceName, + leaveMessageBlocks = payload.messageBlocks.toLeaveMessageBlocks(), + memorial = + buildMemorialWritePayload( + playlistSongs = playlistSongs, + memorialPhotoUrl = memorialMedia.memorialPhotoUrl, + memorialVideoUrl = memorialMedia.memorialVideoUrl, + memorialThumbnailUrl = memorialMedia.memorialThumbnailUrl, + ), + ) + } - AfternoteType.GALLERY_AND_FILES, AfternoteType.SOCIAL_NETWORK, AfternoteType.BUSINESS -> { - buildProcessingMethodsUpdatePayload(type, payload, selectedReceiverIds) - } + AfternoteType.GALLERY_AND_FILES, AfternoteType.SOCIAL_NETWORK, AfternoteType.BUSINESS -> { + buildProcessingMethodsUpdatePayload(type, payload, selectedReceiverIds) + } - // placeholder 카테고리는 Validator 에서 차단됨. 도달 시 호출자 버그. - AfternoteType.ESTATE -> { - error("Unimplemented type cannot be saved: $type") + // placeholder 카테고리는 Validator 에서 차단됨. 도달 시 호출자 버그. + AfternoteType.ESTATE -> { + error("Unimplemented type cannot be saved: $type") + } } - } + return full.omittingFieldsUnchangedFrom(baseline) + } + + /** + * 수정 진입 시 받은 상세를 **「서버가 지금 들고 있는 값」 그대로의 수정 페이로드**로 옮긴다. + * + * [buildUpdatePayload] 가 만드는 현재 폼 페이로드와 **같은 어휘**라, 필드끼리 바로 견줄 수 있다. + * 프리필([buildEditorFormPrefill])을 거치지 않고 [Detail] 에서 직접 만드는 이유는, 프리필이 + * 화면 표시용으로 값을 한 번 가공하기 때문이다 — 비교 기준은 가공 전 원본이어야 한다. + */ + fun buildUpdateBaseline(detail: Detail): AfternoteUpdatePayload { + val content = detail.content + return AfternoteUpdatePayload( + type = content.type, + title = detail.serviceName, + processingMethods = + when (content) { + is DetailContent.SocialNetwork -> content.processingMethods + is DetailContent.Business -> content.processingMethods + is DetailContent.Gallery -> content.processingMethods + is DetailContent.Memorial, DetailContent.Estate -> null + }, + leaveMessageBlocks = detail.leaveMessageBlocks, + credentials = + when (content) { + is DetailContent.SocialNetwork -> content.credentials.toAccountCredentials() + is DetailContent.Business -> content.credentials.toAccountCredentials() + is DetailContent.Gallery, is DetailContent.Memorial, DetailContent.Estate -> null + }, + // 추억 노트 수정 페이로드는 수신자를 싣지 않으므로(아래 when 의 MEMORIAL 분기 참고) + // 기준도 같은 자리를 비워 둔다 — 그래야 「양쪽 다 없음」으로 맞아떨어진다. + receivers = + when (content) { + is DetailContent.Memorial, DetailContent.Estate -> { + null + } + + is DetailContent.SocialNetwork, is DetailContent.Business, is DetailContent.Gallery -> { + detail.receivers.map { ReceiverRefPayload(receiverId = it.receiverId) } + } + }, + memorial = + (content as? DetailContent.Memorial)?.let { memorialContent -> + MemorialWritePayload( + memorialPhotoUrl = memorialContent.memorial.media.photoUrl, + songs = + memorialContent.memorial.songs.map { song -> + MemorialSongPayload( + title = song.title, + artist = song.artist, + coverUrl = song.coverUrl, + ) + }, + memorialVideo = + memorialContent.memorial.media.videoUrl?.ifBlank { null }?.let { url -> + MemorialVideoPayload( + videoUrl = url, + thumbnailUrl = + memorialContent.memorial.media.thumbnailUrl + ?.ifBlank { null }, + ) + }, + ) + }, + ) + } + + private fun DetailCredentials.toAccountCredentials() = + AfternoteAccountCredentials( + id = id.ifBlank { null }, + password = password.ifBlank { null }, + ) /** * 처리 방법 기반 카테고리(SOCIAL·BUSINESS·GALLERY) 공용 update payload — @@ -275,7 +360,9 @@ internal object AfternoteEditorFormMapper { return AfternoteUpdatePayload( type = type, title = payload.serviceName, - processingMethods = processingMethods.ifEmpty { null }, + // 빈 목록을 null 로 접지 않는다 — 「전부 지웠다」와 「안 건드렸다」는 다른 지시이고, + // 후자는 이제 baseline 비교가 판정한다 (#1617). + processingMethods = processingMethods, leaveMessageBlocks = payload.messageBlocks.toLeaveMessageBlocks(), credentials = credentials, receivers = selectedReceiverIds.map { ReceiverRefPayload(receiverId = it) }, @@ -284,6 +371,59 @@ internal object AfternoteEditorFormMapper { } } +/** + * [baseline] 과 값이 같은 필드를 `null` 로 떨어뜨려 요청에서 키째 빠지게 한다 (#1617). + * + * **필드를 빼기만 한다 — 채우지 않는다.** 기준이 없으면([baseline] 이 `null`) 손대지 않고 전량을 + * 그대로 돌려준다. + * + * 비교는 [normalizedForDiff] 를 거친 값끼리 한다. 서버가 준 원본과 폼을 왕복한 값은 공백·빈 문자열· + * 정렬에서 어긋날 수 있고, 그 차이를 「사용자가 고쳤다」로 읽으면 안 건드린 필드가 다시 실린다. + */ +private fun AfternoteUpdatePayload.omittingFieldsUnchangedFrom(baseline: AfternoteUpdatePayload?): AfternoteUpdatePayload { + if (baseline == null) return this + val current = normalizedForDiff() + val original = baseline.normalizedForDiff() + return copy( + title = title.takeIf { current.title != original.title }, + processingMethods = processingMethods.takeIf { current.processingMethods != original.processingMethods }, + leaveMessageBlocks = leaveMessageBlocks.takeIf { current.leaveMessageBlocks != original.leaveMessageBlocks }, + credentials = credentials.takeIf { current.credentials != original.credentials }, + receivers = receivers.takeIf { current.receivers != original.receivers }, + memorial = memorial.takeIf { current.memorial != original.memorial }, + ) +} + +/** + * 「달라졌는가」만 판정하기 위한 정규형 — **전송값이 아니다.** + * + * 서버 원본과 폼 왕복본이 뜻은 같은데 표기만 다른 경우를 흡수한다: 앞뒤 공백, 빈 문자열과 `null`, + * 그리고 뜻이 없는 정렬(수신자는 순서가 의미를 갖지 않는다 — 처리 방법·곡은 사용자가 정한 순서라 + * 정렬하지 않는다). + */ +private fun AfternoteUpdatePayload.normalizedForDiff(): AfternoteUpdatePayload = + copy( + title = title?.trim(), + processingMethods = processingMethods?.map { it.trim() }?.filter { it.isNotEmpty() }, + leaveMessageBlocks = + leaveMessageBlocks + ?.map { LeaveMessageBlock(title = it.title?.trim()?.ifEmpty { null }, body = it.body.trim()) } + ?.filter { it.body.isNotEmpty() }, + credentials = + credentials + ?.let { AfternoteAccountCredentials(id = it.id?.ifBlank { null }, password = it.password?.ifBlank { null }) } + ?.takeIf { it.id != null || it.password != null }, + receivers = receivers?.sortedBy { it.receiverId }, + memorial = + memorial?.let { media -> + MemorialWritePayload( + memorialPhotoUrl = media.memorialPhotoUrl?.ifBlank { null }, + songs = media.songs, + memorialVideo = media.memorialVideo?.takeIf { !it.videoUrl.isNullOrBlank() }, + ) + }, + ) + private fun LeaveMessageBlock.toEditorBlock(): EditorMessageTextBlock = EditorMessageTextBlock( title = title.orEmpty(), diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt index 9ee53f7e37..190460e15e 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt @@ -10,6 +10,7 @@ import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.error.AfternoteFailure +import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository @@ -467,6 +468,7 @@ class AfternoteEditorViewModel selectedReceiverIds = selectedReceiverIds, playlistSongs = playlistSongs, memorialMedia = memorialMediaForSave, + updateBaseline = editorState.updateBaseline, ).fold( onSuccess = { command -> executeSaveCommand(command).fold( @@ -527,6 +529,7 @@ class AfternoteEditorViewModel selectedReceiverIds: List, playlistSongs: List, memorialMedia: SaveAfternoteMemorialMedia, + updateBaseline: AfternoteUpdatePayload?, ): Result { val resolved = resolveMemorialMediaForSave( @@ -552,6 +555,7 @@ class AfternoteEditorViewModel memorialThumbnailUrl = memorialMedia.memorialVideo.displayed?.thumbnailUrl, memorialPhotoUrl = resolved.resolvedMemorialPhotoUrl, ), + baseline = updateBaseline, ) SaveAfternoteCommand.Update(id = editingId, payload = updatePayload) } else { @@ -584,6 +588,9 @@ class AfternoteEditorViewModel it.copy( originalType = prefill.type, pendingPrefill = prefill, + // 화면에 뿌릴 prefill 과 별개로, 가공 전 원본을 저장 때 견줄 기준으로 남긴다. + // 이 값은 폼 변경을 따라가지 않는다 — 따라가면 비교할 대상이 사라진다 (#1617). + updateBaseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail), ) } }.onFailure { e -> @@ -652,6 +659,13 @@ class AfternoteEditorViewModel val pendingThumbnailUrl: String? = null, val memorialThumbnailRetryToken: Int = 0, val pendingPrefill: EditorFormPrefill? = null, + /** + * 수정 진입 시 받은 상세를 그대로 옮긴 **pristine baseline** (#1617). + * + * 저장 시 현재 폼과 견줘 **달라진 필드만** 요청에 싣는 기준이다. `null` 이면 기준이 없다는 + * 뜻이라(신규 작성이거나 상세 로드 실패) 종전처럼 전량을 싣는다. + */ + val updateBaseline: AfternoteUpdatePayload? = null, ) private fun InternalState.toUiState(): AfternoteEditorUiState = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt index ff1e59a463..3b28bb7d0d 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt @@ -80,8 +80,15 @@ class AfternoteAuthorExtendedTest { @get:Rule val composeRule = createComposeRule() + /** + * 상세 → 수정 → 저장을 실제 화면으로 완주하며, **사용자가 만진 필드만** 요청에 실리는지 본다 (#1617). + * + * 여기서 손대는 것은 계정 ID 와 처리 방법 둘뿐이다. 제목·남기실 말씀·수신자는 프리필된 채로 + * 두므로 페이로드에서 빠져야 한다 — 종전에는 폼 스냅샷이 통째로 실려, 에디터를 연 뒤 서버가 + * 바뀌면 만진 적 없는 필드가 낡은 값으로 덮였다. + */ @Test - fun detailEdit_updatesExactPrefilledPayloadThroughEditorRoute() { + fun detailEdit_sendsOnlyTouchedFieldsThroughEditorRoute() { val repository = FakeAfternoteRepository.strict().apply { onGetDetail = { Result.success(authorDetail()) } @@ -138,15 +145,14 @@ class AfternoteAuthorExtendedTest { val (updatedId, payload) = repository.updateCalls.single() assertEquals(73L, updatedId) assertEquals(AfternoteType.SOCIAL_NETWORK, payload.type) - assertEquals("Instagram", payload.title) + // 만진 둘 — 처리 방법과 계정 ID. 계정은 한 덩어리라 안 고친 비밀번호도 함께 실린다. assertEquals(listOf("계정 보존"), payload.processingMethods) - assertEquals( - listOf(LeaveMessageBlock(title = "마지막 말", body = "기억해 줘")), - payload.leaveMessageBlocks, - ) assertEquals("edited@example.test", payload.credentials?.id) assertEquals("old-password", payload.credentials?.password) - assertEquals(listOf(ReceiverRefPayload(7L)), payload.receivers) + // 안 만진 나머지 — 키째 빠져야 서버가 기존 값을 유지한다. + assertNull("제목을 고친 적이 없다", payload.title) + assertNull("남기실 말씀을 고친 적이 없다", payload.leaveMessageBlocks) + assertNull("수신자를 고친 적이 없다", payload.receivers) assertNull(payload.memorial) assertEquals(listOf(73L, 73L), repository.requestedDetailIds) assertEquals("73", routedItemId) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorPartialUpdateTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorPartialUpdateTest.kt new file mode 100644 index 0000000000..d748bfed64 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorPartialUpdateTest.kt @@ -0,0 +1,278 @@ +package com.afternote.feature.afternote.presentation.author.editor + +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.LeaveMessageBlock +import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload +import com.afternote.feature.afternote.domain.model.author.Detail +import com.afternote.feature.afternote.domain.model.author.DetailContent +import com.afternote.feature.afternote.domain.model.author.DetailCredentials +import com.afternote.feature.afternote.domain.model.author.DetailReceiver +import com.afternote.feature.afternote.domain.model.author.DetailTimestamps +import com.afternote.feature.afternote.domain.model.author.ReceiverRefPayload +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 +import com.afternote.feature.afternote.presentation.author.editor.memorial.playlist.Song +import com.afternote.feature.afternote.presentation.author.editor.message.EditorMessageTextBlock +import com.afternote.feature.afternote.presentation.author.editor.model.RegisterAfternotePayload +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 수정 요청이 **사용자가 만진 필드만** 싣는지 고정한다 (#1617). + * + * 종전에는 폼이 든 전체 스냅샷이 매번 통째로 나갔다. 에디터를 연 뒤 서버가 바뀌면, 사용자가 건드린 + * 적도 없는 필드가 낡은 로컬 값으로 덮였다 — 곡을 만진 적 없는 「제목만 수정」이 남이 추가한 곡을 + * 전부 지우는 lost update 다. + * + * 여기서 고정하는 축은 둘이다. + * 1. 안 건드린 필드는 페이로드에서 `null` 이 된다 → 요청에서 키째 빠진다 → 서버가 「유지」로 읽는다. + * 2. **비운 것은 안 건드린 것과 다르다** — 빈 목록은 그대로 실려 「전부 삭제」로 나간다 + * (#1596·#1599 가 세운 계약). + */ +class AfternoteEditorPartialUpdateTest { + private val socialDetail = + Detail( + id = 7L, + serviceName = "인스타그램", + timestamps = DetailTimestamps(updatedAt = "2026-08-30"), + receivers = + listOf( + DetailReceiver(receiverId = 11L, name = "엄마", relation = "가족"), + DetailReceiver(receiverId = 22L, name = "동생", relation = "가족"), + ), + leaveMessageBlocks = listOf(LeaveMessageBlock(title = "가족에게", body = "고마웠어")), + content = + DetailContent.SocialNetwork( + credentials = DetailCredentials(id = "account", password = "pw"), + processingMethods = listOf("계정 삭제", "사진 백업"), + ), + ) + + private val memorialDetail = + Detail( + id = 9L, + serviceName = "추억 노트", + timestamps = DetailTimestamps(updatedAt = "2026-08-30"), + receivers = emptyList(), + leaveMessageBlocks = emptyList(), + content = + DetailContent.Memorial( + memorial = + MemorialDetail( + songs = listOf(DetailSong(title = "곡", artist = "가수", coverUrl = null)), + media = + MemorialMedia( + photoUrl = "https://cdn.test/afternotes/photo.jpg", + videoUrl = "https://cdn.test/afternotes/video.mp4", + thumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + ), + ), + ), + ) + + /** 상세를 그대로 되돌린 폼 — 「아무것도 안 건드린 사용자」다. */ + private fun untouchedSocialPayload( + serviceName: String = "인스타그램", + accountId: String = "account", + password: String = "pw", + processingMethods: List = listOf("계정 삭제", "사진 백업"), + messageBlocks: List = + listOf(EditorMessageTextBlock(title = "가족에게", body = "고마웠어", isRegistered = true)), + ) = RegisterAfternotePayload( + serviceName = serviceName, + date = "2026-08-30", + accountId = accountId, + password = password, + messageBlocks = messageBlocks, + processingMethods = processingMethods, + ) + + private fun buildSocialUpdate( + payload: RegisterAfternotePayload = untouchedSocialPayload(), + selectedReceiverIds: List = listOf(11L, 22L), + baseline: AfternoteUpdatePayload? = AfternoteEditorFormMapper.buildUpdateBaseline(socialDetail), + ) = AfternoteEditorFormMapper.buildUpdatePayload( + type = AfternoteType.SOCIAL_NETWORK, + payload = payload, + selectedReceiverIds = selectedReceiverIds, + playlistSongs = emptyList(), + memorialMedia = MemorialMediaUrls(), + baseline = baseline, + ) + + @Test + fun `제목만 고치면 제목 말고는 아무 필드도 실리지 않는다`() { + val updated = buildSocialUpdate(payload = untouchedSocialPayload(serviceName = "새 제목")) + + assertEquals("새 제목", updated.title) + assertNull("처리 방법을 만진 적이 없다", updated.processingMethods) + assertNull("남기실 말씀을 만진 적이 없다", updated.leaveMessageBlocks) + assertNull("계정 정보를 만진 적이 없다", updated.credentials) + assertNull("수신자를 만진 적이 없다", updated.receivers) + assertNull("추억 노트가 아니므로 애초에 없다", updated.memorial) + } + + @Test + fun `아무것도 안 고치면 어느 필드도 실리지 않는다`() { + val updated = buildSocialUpdate() + + assertNull(updated.title) + assertNull(updated.processingMethods) + assertNull(updated.leaveMessageBlocks) + assertNull(updated.credentials) + assertNull(updated.receivers) + } + + /** 순서가 뜻을 갖지 않는 수신자는 정렬이 달라졌다고 「고쳤다」로 읽지 않는다. */ + @Test + fun `수신자 순서만 바뀐 것은 변경이 아니다`() { + val updated = buildSocialUpdate(selectedReceiverIds = listOf(22L, 11L)) + + assertNull(updated.receivers) + } + + @Test + fun `수신자를 실제로 지우면 남은 목록이 실린다`() { + val updated = buildSocialUpdate(selectedReceiverIds = listOf(11L)) + + assertEquals(listOf(ReceiverRefPayload(receiverId = 11L)), updated.receivers) + } + + /** + * 「전부 삭제」와 「안 건드림」이 갈린다 — 빈 목록은 접히지 않고 그대로 실려야 한다. + * 접어 버리면 사용자가 전부 지운 저장이 서버에 반영되지 않는다. + */ + @Test + fun `수신자를 전부 빼면 빈 목록이 실려 전부 삭제로 나간다`() { + val updated = buildSocialUpdate(selectedReceiverIds = emptyList()) + + assertEquals(emptyList(), updated.receivers) + } + + @Test + fun `처리 방법을 전부 빼면 빈 목록이 실려 전부 삭제로 나간다`() { + val updated = buildSocialUpdate(payload = untouchedSocialPayload(processingMethods = emptyList())) + + assertEquals(emptyList(), updated.processingMethods) + } + + @Test + fun `남기실 말씀을 전부 빼면 빈 목록이 실려 전부 삭제로 나간다`() { + val updated = buildSocialUpdate(payload = untouchedSocialPayload(messageBlocks = emptyList())) + + assertEquals(emptyList(), updated.leaveMessageBlocks) + } + + @Test + fun `계정 정보를 고치면 계정 정보가 실린다`() { + val updated = buildSocialUpdate(payload = untouchedSocialPayload(password = "새 비밀번호")) + + assertEquals("새 비밀번호", updated.credentials?.password) + assertNull("제목은 그대로다", updated.title) + } + + /** + * 에디터가 띄워 두는 빈 입력 칸은 도메인으로 넘어가기 전에 버려진다. 그 결과가 서버 원본과 + * 같다면 「고쳤다」가 아니다 — 여기서 흔들리면 저장할 때마다 남기실 말씀이 다시 실린다. + */ + @Test + fun `빈 입력 칸과 앞뒤 공백은 변경으로 세지 않는다`() { + val updated = + buildSocialUpdate( + payload = + untouchedSocialPayload( + messageBlocks = + listOf( + EditorMessageTextBlock(title = " 가족에게 ", body = " 고마웠어 ", isRegistered = true), + EditorMessageTextBlock(title = "", body = "", isRegistered = false), + ), + ), + ) + + assertNull(updated.leaveMessageBlocks) + } + + /** 기준이 없으면(상세 로드 실패 등) 덜 보내다 사용자의 편집을 잃느니 종전처럼 전량을 싣는다. */ + @Test + fun `기준 스냅샷이 없으면 종전처럼 전량을 싣는다`() { + val updated = buildSocialUpdate(baseline = null) + + assertEquals("인스타그램", updated.title) + assertEquals(listOf("계정 삭제", "사진 백업"), updated.processingMethods) + assertNotNull(updated.leaveMessageBlocks) + assertNotNull(updated.credentials) + assertEquals(2, updated.receivers?.size) + } + + private fun buildMemorialUpdate( + serviceName: String = "추억 노트", + playlistSongs: List = + listOf(Song(selectionKey = "detail:0", title = "곡", artist = "가수", albumCoverUrl = null)), + memorialMedia: MemorialMediaUrls = + MemorialMediaUrls( + memorialVideoUrl = "https://cdn.test/afternotes/video.mp4", + memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + memorialPhotoUrl = "https://cdn.test/afternotes/photo.jpg", + ), + ) = AfternoteEditorFormMapper.buildUpdatePayload( + type = AfternoteType.MEMORIAL, + payload = RegisterAfternotePayload(serviceName = serviceName, date = "2026-08-30"), + selectedReceiverIds = emptyList(), + playlistSongs = playlistSongs, + memorialMedia = memorialMedia, + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(memorialDetail), + ) + + /** + * 이슈가 든 재현 경로 그대로다 — 곡을 건드리지 않은 「제목만 수정」이 곡을 지우면 안 된다. + * `playlist` 키가 아예 나가지 않아야 서버가 곡·사진·영상을 통째로 유지한다. + */ + @Test + fun `추억 노트도 제목만 고치면 플레이리스트를 말하지 않는다`() { + val updated = buildMemorialUpdate(serviceName = "새 제목") + + assertEquals("새 제목", updated.title) + assertNull("곡을 건드린 적이 없다", updated.memorial) + } + + @Test + fun `곡을 전부 빼면 플레이리스트가 빈 곡 목록과 함께 실린다`() { + val updated = buildMemorialUpdate(playlistSongs = emptyList()) + + val memorial = requireNotNull(updated.memorial) { "곡을 지웠으므로 플레이리스트가 실려야 한다" } + assertTrue("곡을 지운 것은 명시적 삭제다", memorial.songs.isEmpty()) + assertEquals("같이 실리는 사진은 종전 값 그대로다", "https://cdn.test/afternotes/photo.jpg", memorial.memorialPhotoUrl) + } + + /** 선택 키는 화면 전용 식별자라 서버로 나가지 않는다 — 재조회로 값이 달라져도 변경이 아니다. */ + @Test + fun `곡 선택 키가 달라도 곡 내용이 같으면 변경이 아니다`() { + val updated = + buildMemorialUpdate( + playlistSongs = + listOf(Song(selectionKey = "search:가수|곡|0", title = "곡", artist = "가수", albumCoverUrl = null)), + ) + + assertNull(updated.memorial) + } + + @Test + fun `영정 사진을 지우면 플레이리스트가 실려 삭제를 말한다`() { + val updated = + buildMemorialUpdate( + memorialMedia = + MemorialMediaUrls( + memorialVideoUrl = "https://cdn.test/afternotes/video.mp4", + memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + memorialPhotoUrl = null, + ), + ) + + assertNotNull(updated.memorial) + assertNull("빈 사진은 명시적 null 로 나가 삭제가 된다", updated.memorial?.memorialPhotoUrl) + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt index 421b982865..24fe4ba8cf 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt @@ -94,6 +94,9 @@ class EditorLeaveMessageBlocksTest { selectedReceiverIds = emptyList(), playlistSongs = emptyList(), memorialMedia = MemorialMediaUrls(), + // 여기서 보는 것은 블록을 *어떻게 빚는가* 라서, 변경 판정이 끼어들지 않게 기준을 비운다. + // 안 건드린 필드를 빼는 축은 AfternoteEditorPartialUpdateTest 가 따로 고정한다 (#1617). + baseline = null, ) assertEquals( @@ -131,6 +134,9 @@ class EditorLeaveMessageBlocksTest { selectedReceiverIds = emptyList(), playlistSongs = emptyList(), memorialMedia = MemorialMediaUrls(), + // 여기서 보는 것은 블록을 *어떻게 빚는가* 라서, 변경 판정이 끼어들지 않게 기준을 비운다. + // 안 건드린 필드를 빼는 축은 AfternoteEditorPartialUpdateTest 가 따로 고정한다 (#1617). + baseline = null, ) assertEquals( From c3355301e8a7ca4e939bae5e09c5010806ab6e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 31 Aug 2026 02:07:46 +0900 Subject: [PATCH 02/15] =?UTF-8?q?docs(afternote):=20baseline=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=EC=9D=98=20=EC=83=81=ED=98=B8=20=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=AC=EB=B3=BC=20=EC=9D=B4=EB=A6=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=94=EB=A1=9C=EC=9E=A1=EB=8A=94=EB=8B=A4=20(#1?= =?UTF-8?q?617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/author/editor/AfternoteEditorFormMapper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt index 90f329c652..383ebbc95d 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt @@ -293,7 +293,7 @@ internal object AfternoteEditorFormMapper { is DetailContent.Business -> content.credentials.toAccountCredentials() is DetailContent.Gallery, is DetailContent.Memorial, DetailContent.Estate -> null }, - // 추억 노트 수정 페이로드는 수신자를 싣지 않으므로(아래 when 의 MEMORIAL 분기 참고) + // 추억 노트 수정 페이로드는 수신자를 싣지 않으므로([buildUpdatePayload] 의 MEMORIAL 분기) // 기준도 같은 자리를 비워 둔다 — 그래야 「양쪽 다 없음」으로 맞아떨어진다. receivers = when (content) { From ceff01f4e0f679a903ce66e99ebe8af1659c1816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 31 Aug 2026 02:45:52 +0900 Subject: [PATCH 03/15] =?UTF-8?q?fix(afternote):=20=EB=B6=80=EB=B6=84=20PA?= =?UTF-8?q?TCH=20=ED=8C=90=EC=A0=95=EC=9D=84=20=EC=84=9C=EB=B2=84=EA=B0=80?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=ED=95=98=EB=8A=94=20=EC=8A=AC=EB=A1=AF=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=EB=A1=9C=20=EB=82=B4=EB=A6=B0=EB=8B=A4=20(#1?= =?UTF-8?q?617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 객체째 「바뀌었나」를 재던 것을 슬롯 단위로 좁힌다. 덩어리로 재면 그 안에서 안 건드린 형제 슬롯이 낡은 값째 함께 실려, 이 이슈가 없애려던 덮어쓰기를 한 단계 아래에서 그대로 재생산한다. - 플레이리스트: 수정 전용 DTO 를 세우고 사진·영상 슬롯을 FieldPatch 3상태로 연다. 종전에는 곡만 바꾼 저장이 기본값 없는 사진·영상 슬롯을 명시적 null 로 함께 실어 그 사이 올라온 미디어를 지웠다. - 계정 정보: 서버가 id·비밀번호를 독립으로 갱신하므로 따로 잰다. 비밀번호만 고친 저장이 안 고친 id 를 되돌리던 것을 막는다. - 처리 방법 비교에서 빈 문자열 필터를 뺀다. 서버는 actions 원소를 검증 없이 저장하므로, 걷어내면 `[""]` 한 행을 지운 저장이 상쇄돼 삭제가 사라진다. - 기준 스냅샷 없는 수정 저장을 막고 안내한다. 상세 조회가 실패하면 빈 폼이 그대로 전량 PATCH 로 나가 처리 방법·수신자·말씀을 지웠다 — 제목만 채우면 GALLERY·MEMORIAL 은 Validator 도 통과했다. 타입에서도 baseline 을 필수로 바꿔 「기준 없이 전량 전송」 경로 자체를 없앤다. 와이어 계약은 JSON 문자열째 단언한다 — 키를 뺐는지와 null 을 실었는지는 객체 수준에서 구분되지 않아, 이 결함이 정확히 그 틈으로 샜다. 프로세스 사망 복원이 미저장 편집을 프리필로 덮는 경로는 이 PR 이 만들지도 고치지도 않는다. 회귀 가드로 드러내 두고 한계로 남긴다. Refs #1617 --- .../afternote/data/dto/AfternoteDto.kt | 36 ++- .../data/dto/FieldPatchSerializer.kt | 46 +++ .../data/mapper/AfternoteRequestMapper.kt | 26 +- .../dto/AfternotePlaylistRequestWireTest.kt | 32 +- .../dto/AfternoteResponseDtoContractTest.kt | 9 +- .../AfternoteUpdatePartialPatchWireTest.kt | 257 +++++++++------- .../model/author/AfternoteUpdatePayload.kt | 15 +- .../domain/model/author/FieldPatch.kt | 41 +++ .../model/author/MemorialWritePayload.kt | 25 ++ .../domain/testing/FakeAfternoteRepository.kt | 27 +- .../editor/AfternoteEditorFormMapper.kt | 287 +++++++++--------- .../editor/AfternoteEditorRouteHelpers.kt | 4 + .../author/editor/AfternoteEditorViewModel.kt | 21 +- .../author/editor/state/AfternoteSaveState.kt | 9 + .../src/main/res/values/strings.xml | 1 + .../author/AfternoteAuthorExtendedTest.kt | 10 +- .../AfternoteEditorMissingBaselineTest.kt | 162 ++++++++++ .../AfternoteEditorPartialUpdateTest.kt | 142 ++++++--- .../AfternoteEditorProcessDeathPrefillTest.kt | 137 +++++++++ .../editor/EditorLeaveMessageBlocksTest.kt | 46 ++- 20 files changed, 1016 insertions(+), 317 deletions(-) create mode 100644 feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/FieldPatchSerializer.kt create mode 100644 feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/FieldPatch.kt create mode 100644 feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorMissingBaselineTest.kt create mode 100644 feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorProcessDeathPrefillTest.kt 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 f87002c5c0..92374ba4fd 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 @@ -1,5 +1,6 @@ package com.afternote.feature.afternote.data.dto +import com.afternote.feature.afternote.domain.model.author.FieldPatch import kotlinx.serialization.EncodeDefault import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName @@ -61,7 +62,7 @@ data class AfternoteUpdateRequestDto( @SerialName("leaveMessage") val leaveMessage: List? = null, @SerialName("credentials") val credentials: AfternoteCredentialsDto? = null, @SerialName("receivers") val receivers: List? = null, - @SerialName("playlist") val memorial: AfternotePlaylistRequestDto? = null, + @SerialName("playlist") val memorial: AfternotePlaylistPatchDto? = null, ) @Serializable @@ -121,6 +122,39 @@ data class AfternotePlaylistRequestDto( @SerialName("memorialVideo") val memorialVideo: AfternoteMemorialVideoDto?, ) +/** + * 추억 노트 플레이리스트 **수정(PATCH) 전용** 바디 — 만진 슬롯만 말한다 (#1617). + * + * [AfternotePlaylistRequestDto] 와 나눠 둔 이유가 계약 그 자체다. 생성은 「이 노트가 가져야 할 + * 상태」를 통째로 말해야 해서 슬롯에 기본값을 두지 않는다(그래서 항상 직렬화된다). 수정은 반대다 — + * **만지지 않은 슬롯은 키째 빠져야** 서버가 기존 값을 유지한다. + * + * 한 DTO 로 겸하면 둘 중 하나가 반드시 깨진다. 실제로 그랬다: 수정에서 생성용 DTO 를 쓰는 동안, + * 곡만 바꾼 저장이 `{"memorialPhotoUrl":null,"memorialVideo":null}` 을 함께 실어 **그 사이 다른 + * 기기가 올린 영정사진·추모 영상을 지웠다.** 기본값이 없는 슬롯은 `encodeDefaults = false` 로도 + * 생략되지 않고, `null` 이면 곧 삭제 지시이기 때문이다. + * + * 슬롯별 표현이 다른 것은 서버가 그렇게 읽기 때문이다: + * - [memorialPhotoUrl]·[memorialVideo] — `PlaylistRequestDeserializer` 가 `node.has(...)` 로 키 + * 유무를 보고 `AfternotePlaylist.update` 가 그 플래그로 유지/삭제를 가른다. 3상태가 필요해 + * [FieldPatch] 를 쓴다. 기본값 [FieldPatch.Unchanged] 가 곧 「키 생략」이다. + * - [songs] — `PlaylistRelationStrategy.update` 가 `songs != null` 일 때만 `items` 를 통째로 갈아 + * 끼운다. `null`(안 건드림)과 `[]`(전부 삭제)로 두 뜻이 다 나오므로 [FieldPatch] 가 필요 없다. + * - `atmosphere`·`memorialAudioUrl` — FE 화면에 없는 값이라 삭제를 지시할 자격이 없다. 슬롯 자체를 + * 두지 않아 영영 생략된다. + */ +@Serializable +data class AfternotePlaylistPatchDto( + @SerialName("memorialPhotoUrl") + @Serializable(with = FieldPatchSerializer::class) + val memorialPhotoUrl: FieldPatch = FieldPatch.Unchanged, + @SerialName("songs") + val songs: List? = null, + @SerialName("memorialVideo") + @Serializable(with = FieldPatchSerializer::class) + val memorialVideo: FieldPatch = FieldPatch.Unchanged, +) + @Serializable data class AfternoteSongDto( @SerialName("title") val title: String, diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/FieldPatchSerializer.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/FieldPatchSerializer.kt new file mode 100644 index 0000000000..c933d48dde --- /dev/null +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/FieldPatchSerializer.kt @@ -0,0 +1,46 @@ +package com.afternote.feature.afternote.data.dto + +import com.afternote.feature.afternote.domain.model.author.FieldPatch +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * [FieldPatch] 슬롯을 wire 로 옮기는 직렬화기 (#1617). + * + * **「키를 뺀다」는 이 직렬화기가 하는 일이 아니다.** kotlinx 는 `encodeDefaults = false` 에서 + * *선언된 기본값과 같은* 필드만 생략하므로, 생략은 DTO 가 슬롯의 기본값을 [FieldPatch.Unchanged] 로 + * 두는 것으로 성립한다. 이 직렬화기는 나머지 둘만 맡는다 — [FieldPatch.Set] 의 값을 그대로 쓰고, + * 그 값이 `null` 이면 JSON `null` 을 남겨 서버가 삭제로 읽게 한다. + * + * 그래서 [FieldPatch.Unchanged] 가 여기까지 오면 계약이 깨진 것이다: DTO 슬롯의 기본값이 + * [FieldPatch.Unchanged] 가 아니거나 `encodeDefaults` 가 켜졌다는 뜻이고, 둘 다 「안 건드림」을 + * 「삭제」로 바꿔 버린다. 조용히 null 을 쓰지 않고 즉시 실패시킨다. + */ +class FieldPatchSerializer( + private val valueSerializer: KSerializer, +) : KSerializer> { + override val descriptor: SerialDescriptor = valueSerializer.descriptor + + override fun serialize( + encoder: Encoder, + value: FieldPatch, + ) { + when (value) { + is FieldPatch.Unchanged -> { + error( + "FieldPatch.Unchanged 는 직렬화 대상이 아니다 — 키가 생략됐어야 한다. " + + "DTO 슬롯의 기본값과 Json 의 encodeDefaults 설정을 확인할 것.", + ) + } + + is FieldPatch.Set -> { + encoder.encodeSerializableValue(valueSerializer, value.value) + } + } + } + + /** 요청 전용 타입이라 역직렬화는 쓰이지 않지만, 계약상 값이 있으면 [FieldPatch.Set] 이다. */ + override fun deserialize(decoder: Decoder): FieldPatch = FieldPatch.Set(decoder.decodeSerializableValue(valueSerializer)) +} diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt index 6d4555a2ee..1b0871c6c3 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt @@ -5,6 +5,7 @@ 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.AfternotePlaylistPatchDto import com.afternote.feature.afternote.data.dto.AfternotePlaylistRequestDto import com.afternote.feature.afternote.data.dto.AfternoteReceiverRefDto import com.afternote.feature.afternote.data.dto.AfternoteSongDto @@ -15,6 +16,8 @@ import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayloa 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.FieldPatch +import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload 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 @@ -35,9 +38,30 @@ fun AfternoteUpdatePayload.toRequest() = leaveMessage = leaveMessageBlocks.toPatchDto(), credentials = credentials?.toDto(), receivers = receivers?.map { it.toDto() }, - memorial = memorial?.toDto(), + memorial = memorial?.toPatchDto(), ) +/** + * 플레이리스트 수정 슬롯 → wire. 슬롯의 [FieldPatch] 를 **그대로** 옮긴다 (#1617). + * + * [FieldPatch.Unchanged] 를 여기서 `null` 이나 기존 값으로 바꿔치기하면 안 된다 — 전자는 삭제로, + * 후자는 남의 변경을 되돌리는 덮어쓰기로 나간다. 「말하지 않음」은 끝까지 말하지 않는 것으로만 + * 표현된다. + */ +fun MemorialPatchPayload.toPatchDto() = + AfternotePlaylistPatchDto( + memorialPhotoUrl = memorialPhotoUrl, + songs = songs?.map { it.toDto() }, + memorialVideo = memorialVideo.map { it?.toDto() }, + ) + +/** 슬롯을 열어 값만 바꾸고 「안 건드림」은 그대로 통과시킨다. */ +private inline fun FieldPatch.map(transform: (T) -> R): FieldPatch = + when (this) { + is FieldPatch.Unchanged -> FieldPatch.Unchanged + is FieldPatch.Set -> FieldPatch.Set(transform(value)) + } + /** 문자열 변환은 data 경계에서 끝낸다 — 화면·domain 은 [AfternoteType] 만 다룬다. */ private fun AfternoteType.toAuthoringServerCategory(): String = when (this) { diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternotePlaylistRequestWireTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternotePlaylistRequestWireTest.kt index 682209e64d..e4da47ad48 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternotePlaylistRequestWireTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternotePlaylistRequestWireTest.kt @@ -4,9 +4,10 @@ import com.afternote.core.network.di.NetworkModule import com.afternote.feature.afternote.data.mapper.toRequest import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload +import com.afternote.feature.afternote.domain.model.author.FieldPatch +import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload 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 kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonArray @@ -28,7 +29,7 @@ import org.junit.Test class AfternotePlaylistRequestWireTest { private val json = NetworkModule.provideJson() - private fun updateBody(memorial: MemorialWritePayload?) = + private fun updateBody(memorial: MemorialPatchPayload?) = json .encodeToJsonElement( AfternoteUpdatePayload( @@ -38,20 +39,33 @@ class AfternotePlaylistRequestWireTest { ).toRequest(), ).jsonObject - private fun playlistOf(memorial: MemorialWritePayload) = updateBody(memorial).getValue("playlist").jsonObject + private fun playlistOf(memorial: MemorialPatchPayload) = updateBody(memorial).getValue("playlist").jsonObject private val filled = - MemorialWritePayload( - memorialPhotoUrl = "https://cdn.test/afternotes/photo.jpg", + MemorialPatchPayload( + memorialPhotoUrl = FieldPatch.Set("https://cdn.test/afternotes/photo.jpg"), songs = listOf(MemorialSongPayload(title = "곡", artist = "가수", coverUrl = null)), memorialVideo = - MemorialVideoPayload( - videoUrl = "https://cdn.test/afternotes/video.mp4", - thumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + FieldPatch.Set( + MemorialVideoPayload( + videoUrl = "https://cdn.test/afternotes/video.mp4", + thumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + ), ), ) - private val emptied = MemorialWritePayload(memorialPhotoUrl = null, songs = emptyList(), memorialVideo = null) + /** + * 사용자가 사진·영상·곡을 **모두 비운** 저장. 셋 다 「만졌고 비웠다」이므로 세 슬롯이 다 실린다. + * + * 만지지 않은 슬롯이 여기 섞이면 안 된다는 것이 #1617 의 축이고, 그쪽은 + * [AfternoteUpdatePartialPatchWireTest] 가 JSON 문자열째 고정한다. + */ + private val emptied = + MemorialPatchPayload( + memorialPhotoUrl = FieldPatch.Set(null), + songs = emptyList(), + memorialVideo = FieldPatch.Set(null), + ) @Test fun `영정사진이 비면 키를 남긴 채 null 이 실려 삭제로 나간다`() { diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteResponseDtoContractTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteResponseDtoContractTest.kt index ca97a88b62..db8304fd41 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteResponseDtoContractTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteResponseDtoContractTest.kt @@ -4,6 +4,7 @@ import com.afternote.feature.afternote.data.mapper.toRequest import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload +import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload import com.afternote.feature.afternote.domain.model.author.MemorialSongPayload import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload import kotlinx.serialization.ExperimentalSerializationApi @@ -88,6 +89,7 @@ class AfternoteResponseDtoContractTest { val request = CreateMemorialPayload( title = "추억 노트", + // 생성은 「이 노트가 가져야 할 상태」를 통째로 말하므로 전체 스냅샷 타입 그대로다. memorial = MemorialWritePayload(memorialPhotoUrl = null, songs = emptyList(), memorialVideo = null), ).toRequest() @@ -115,7 +117,8 @@ class AfternoteResponseDtoContractTest { AfternoteUpdatePayload( type = AfternoteType.MEMORIAL, title = "추억 노트", - memorial = MemorialWritePayload(memorialPhotoUrl = null, songs = emptyList(), memorialVideo = null), + // 곡을 전부 뺀 것만 말한다 — 미디어 슬롯은 만지지 않았으므로 키가 나가면 안 된다 (#1617). + memorial = MemorialPatchPayload(songs = emptyList()), ).toRequest() val encoded = json.encodeToJsonElement(AfternoteUpdateRequestDto.serializer(), request).jsonObject @@ -134,10 +137,8 @@ class AfternoteResponseDtoContractTest { type = AfternoteType.MEMORIAL, title = "추억 노트", memorial = - MemorialWritePayload( - memorialPhotoUrl = null, + MemorialPatchPayload( songs = listOf(MemorialSongPayload(title = "곡", artist = "가수", coverUrl = null)), - memorialVideo = null, ), ).toRequest() diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt index 3694ed2cd5..3dd02ef54a 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt @@ -6,93 +6,198 @@ import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock 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.MemorialWritePayload +import com.afternote.feature.afternote.domain.model.author.FieldPatch +import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload +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.ReceiverRefPayload -import kotlinx.serialization.json.encodeToJsonElement -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue import org.junit.Test /** - * 수정 요청 바디의 **키 유무**를 고정한다 (#1617). + * 수정 요청 바디를 **JSON 문자열째** 고정한다 (#1617). * - * 서버는 「키 없음 = 기존 값 유지」로 읽으므로(Afternote-BE `bbff47c` · BE#200·#201), 사용자가 만지지 - * 않은 필드를 빼는 것이 곧 lost update 방지다. 어느 필드를 뺄지는 `AfternoteEditorFormMapper` 가 - * 정하고, **정말로 wire 에서 빠지는지**는 여기서 못박는다. + * DTO 객체만 단언하면 이 축의 결함이 통째로 빠져나간다 — 슬롯이 「키를 뺐는지」와 「null 을 실었는지」는 + * 직렬화 설정과 기본값의 함수라서, 객체 수준에서는 둘이 똑같아 보인다. 실제로 그렇게 새어 나갔다: + * 기본값 없는 `memorialPhotoUrl` 이 `null` 이면 키를 달고 나가 삭제 지시가 됐다. * - * [AfternotePlaylistRequestWireTest] 와 같은 이유로 테스트용 Json 을 새로 만들지 않고 앱이 실제로 - * 쓰는 [NetworkModule.provideJson] 을 그대로 태운다 — 이 계약은 값이 아니라 *직렬화 설정* 위에 - * 서 있어서, 누가 `encodeDefaults` 를 켜면 여기서 깨져야 한다. + * 그래서 앱이 실제로 쓰는 [NetworkModule.provideJson] 으로 인코딩한 **문자열 전체**를 견준다. + * 누가 `encodeDefaults` 를 켜거나 슬롯 기본값을 떼면 여기서 깨진다. */ class AfternoteUpdatePartialPatchWireTest { private val json = NetworkModule.provideJson() - private fun body(payload: AfternoteUpdatePayload) = json.encodeToJsonElement(payload.toRequest()).jsonObject + private fun wire(payload: AfternoteUpdatePayload): String = json.encodeToString(payload.toRequest()) /** - * 이슈 #1617 이 든 재현 경로의 반대편이다 — 제목만 고친 저장은 나머지를 **말하지 않는다.** - * 여기 어느 키 하나라도 되살아나면, 에디터를 연 뒤 서버가 바뀐 경우 그 필드가 낡은 값으로 덮인다. + * 이슈 #1617 이 든 재현 경로의 반대편 — 제목만 고친 저장은 나머지를 **말하지 않는다.** + * 키 하나라도 되살아나면 에디터를 연 뒤 서버가 바뀐 경우 그 필드가 낡은 값으로 덮인다. */ @Test - fun `제목만 실은 수정은 나머지 키가 바디에 아예 없다`() { - val patch = body(AfternoteUpdatePayload(type = AfternoteType.SOCIAL_NETWORK, title = "새 제목")) - - assertEquals("새 제목", patch.getValue("title").jsonPrimitive.content) - assertFalse("playlist" in patch) - assertFalse("receivers" in patch) - assertFalse("actions" in patch) - assertFalse("leaveMessage" in patch) - assertFalse("credentials" in patch) + fun `제목만 실은 수정은 카테고리와 제목 두 키만 나간다`() { + val body = wire(AfternoteUpdatePayload(type = AfternoteType.SOCIAL_NETWORK, title = "새 제목")) + + assertEquals("""{"category":"SOCIAL","title":"새 제목"}""", body) } /** 제목조차 안 건드렸으면 제목도 빠진다 — 서버가 `title` 생략을 계약으로 못박아 뒀다. */ @Test - fun `제목을 안 건드리면 title 키도 빠진다`() { - val patch = body(AfternoteUpdatePayload(type = AfternoteType.SOCIAL_NETWORK)) + fun `아무것도 안 실으면 카테고리만 나간다`() { + val body = wire(AfternoteUpdatePayload(type = AfternoteType.MEMORIAL)) - assertFalse("title" in patch) + assertEquals("""{"category":"PLAYLIST"}""", body) } /** - * 카테고리만 늘 나간다 — 값을 바꾸는 필드가 아니라 **대상 확인용 단언**이다. - * 서버는 저장값과 다르면 400 을 내고, 같으면 아무것도 바꾸지 않는다. + * **곡만 고친 저장이 사진·영상을 지우면 안 된다.** + * + * 이 PR 이 처음 고쳤을 때 남아 있던 결함이 정확히 여기였다. `playlist` 를 한 덩어리로 판정해 + * 곡 변경만으로 세 슬롯을 함께 실었고, 기본값 없는 사진·영상 슬롯이 `null` 로 직렬화돼 + * `{"memorialPhotoUrl":null,"memorialVideo":null}` 이 삭제 지시로 나갔다. */ @Test - fun `카테고리는 아무것도 안 고쳐도 늘 실린다`() { - val patch = body(AfternoteUpdatePayload(type = AfternoteType.SOCIAL_NETWORK)) + fun `곡만 바꾼 수정은 playlist 안에 songs 키만 남긴다`() { + val body = + wire( + AfternoteUpdatePayload( + type = AfternoteType.MEMORIAL, + memorial = + MemorialPatchPayload( + songs = listOf(MemorialSongPayload(title = "곡", artist = "가수", coverUrl = null)), + ), + ), + ) - assertEquals("SOCIAL", patch.getValue("category").jsonPrimitive.content) + assertEquals( + """{"category":"PLAYLIST","playlist":{"songs":[{"title":"곡","artist":"가수"}]}}""", + body, + ) + } + + /** 곡을 전부 뺀 것은 삭제 지시다 — 빈 배열이 나가고, 미디어 슬롯은 여전히 빠져 있어야 한다. */ + @Test + fun `곡을 전부 빼면 빈 배열만 나가고 미디어 키는 없다`() { + val body = + wire( + AfternoteUpdatePayload( + type = AfternoteType.MEMORIAL, + memorial = MemorialPatchPayload(songs = emptyList()), + ), + ) + + assertEquals("""{"category":"PLAYLIST","playlist":{"songs":[]}}""", body) + } + + /** 사진을 지운 저장만 사진 슬롯에 명시적 null 을 싣는다 — 곡·영상 키는 나가지 않는다. */ + @Test + fun `영정 사진만 지우면 사진 슬롯에만 명시적 null 이 나간다`() { + val body = + wire( + AfternoteUpdatePayload( + type = AfternoteType.MEMORIAL, + memorial = MemorialPatchPayload(memorialPhotoUrl = FieldPatch.Set(null)), + ), + ) + + assertEquals("""{"category":"PLAYLIST","playlist":{"memorialPhotoUrl":null}}""", body) + } + + @Test + fun `추모 영상만 지우면 영상 슬롯에만 명시적 null 이 나간다`() { + val body = + wire( + AfternoteUpdatePayload( + type = AfternoteType.MEMORIAL, + memorial = MemorialPatchPayload(memorialVideo = FieldPatch.Set(null)), + ), + ) + + assertEquals("""{"category":"PLAYLIST","playlist":{"memorialVideo":null}}""", body) + } + + @Test + fun `미디어에 값을 실으면 종전과 같은 모양으로 나간다`() { + val body = + wire( + AfternoteUpdatePayload( + type = AfternoteType.MEMORIAL, + memorial = + MemorialPatchPayload( + memorialPhotoUrl = FieldPatch.Set("https://cdn.test/afternotes/photo.jpg"), + memorialVideo = + FieldPatch.Set( + MemorialVideoPayload( + videoUrl = "https://cdn.test/afternotes/video.mp4", + thumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + ), + ), + ), + ), + ) + + assertEquals( + """{"category":"PLAYLIST","playlist":{"memorialPhotoUrl":"https://cdn.test/afternotes/photo.jpg",""" + + """"memorialVideo":{"videoUrl":"https://cdn.test/afternotes/video.mp4",""" + + """"thumbnailUrl":"https://cdn.test/afternotes/thumb.jpg"}}}""", + body, + ) + } + + /** + * **비밀번호만 고친 저장이 아이디를 되돌리면 안 된다.** + * + * 서버는 `CredentialsRelationSupport.update` 에서 id·비밀번호를 각각 `!= null` 일 때만 갈아 + * 끼운다. 안 고친 아이디를 함께 실으면 그 사이 다른 기기가 바꾼 값이 낡은 값으로 되돌아간다. + */ + @Test + fun `비밀번호만 고치면 credentials 안에 password 키만 나간다`() { + val body = + wire( + AfternoteUpdatePayload( + type = AfternoteType.SOCIAL_NETWORK, + credentials = AfternoteAccountCredentials(password = "새 비밀번호"), + ), + ) + + assertEquals("""{"category":"SOCIAL","credentials":{"password":"새 비밀번호"}}""", body) } - /** 「전부 삭제」는 「안 건드림」과 다른 지시다 — 빈 배열이 키를 달고 나가야 서버가 지운다. */ @Test - fun `빈 목록은 생략되지 않고 빈 배열로 실린다`() { - val patch = - body( + fun `아이디만 고치면 credentials 안에 id 키만 나간다`() { + val body = + wire( AfternoteUpdatePayload( type = AfternoteType.SOCIAL_NETWORK, + credentials = AfternoteAccountCredentials(id = "새 아이디"), + ), + ) + + assertEquals("""{"category":"SOCIAL","credentials":{"id":"새 아이디"}}""", body) + } + + /** 「전부 삭제」는 「안 건드림」과 다른 지시다 — 빈 배열이 키를 달고 나가야 서버가 지운다. */ + @Test + fun `빈 목록은 생략되지 않고 빈 배열로 나간다`() { + val body = + wire( + AfternoteUpdatePayload( + type = AfternoteType.GALLERY_AND_FILES, processingMethods = emptyList(), leaveMessageBlocks = emptyList(), receivers = emptyList(), ), ) - assertTrue("actions" in patch) - assertTrue(patch.getValue("actions").jsonArray.isEmpty()) - assertTrue("leaveMessage" in patch) - assertTrue(patch.getValue("leaveMessage").jsonArray.isEmpty()) - assertTrue("receivers" in patch) - assertTrue(patch.getValue("receivers").jsonArray.isEmpty()) + assertEquals( + """{"category":"GALLERY","actions":[],"leaveMessage":[],"receivers":[]}""", + body, + ) } @Test fun `값을 실은 필드는 종전과 같은 키로 그대로 나간다`() { - val patch = - body( + val body = + wire( AfternoteUpdatePayload( type = AfternoteType.BUSINESS, title = "제목", @@ -103,63 +208,11 @@ class AfternoteUpdatePartialPatchWireTest { ), ) - assertEquals("BUSINESS", patch.getValue("category").jsonPrimitive.content) - assertEquals( - "계정 삭제", - patch - .getValue("actions") - .jsonArray - .single() - .jsonPrimitive.content, - ) assertEquals( - "고마웠어", - patch - .getValue("leaveMessage") - .jsonArray - .single() - .jsonObject - .getValue("body") - .jsonPrimitive.content, + """{"category":"BUSINESS","title":"제목","actions":["계정 삭제"],""" + + """"leaveMessage":[{"title":"가족에게","body":"고마웠어"}],""" + + """"credentials":{"id":"account","password":"pw"},"receivers":[{"receiverId":11}]}""", + body, ) - assertEquals( - "account", - patch - .getValue("credentials") - .jsonObject - .getValue("id") - .jsonPrimitive.content, - ) - assertEquals( - 11L, - patch - .getValue("receivers") - .jsonArray - .single() - .jsonObject - .getValue("receiverId") - .jsonPrimitive.content - .toLong(), - ) - } - - /** - * 추억 노트의 미디어 삭제는 **명시적 null** 이라 [MemorialWritePayload] 를 실을 때만 나간다 (#1596). - * 「안 건드림」은 그 바깥 — `playlist` 키 자체가 빠지는 것으로 말한다. - */ - @Test - fun `플레이리스트를 안 건드리면 미디어 삭제 지시도 나가지 않는다`() { - val untouched = body(AfternoteUpdatePayload(type = AfternoteType.MEMORIAL, title = "새 제목")) - val emptied = - body( - AfternoteUpdatePayload( - type = AfternoteType.MEMORIAL, - memorial = MemorialWritePayload(memorialPhotoUrl = null, songs = emptyList(), memorialVideo = null), - ), - ) - - assertFalse("playlist" in untouched) - assertTrue("playlist" in emptied) - assertTrue("memorialPhotoUrl" in emptied.getValue("playlist").jsonObject) } } diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AfternoteUpdatePayload.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AfternoteUpdatePayload.kt index 24cdc34b7e..57c258eacf 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AfternoteUpdatePayload.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AfternoteUpdatePayload.kt @@ -28,8 +28,10 @@ import com.afternote.feature.afternote.domain.model.LeaveMessageBlock * 서버는 저장된 카테고리와 다르면 400 을 내고(`CATEGORY_CANNOT_BE_CHANGED`), 같으면 아무것도 * 바꾸지 않는다. 카테고리는 수정 화면에서 애초에 바꿀 수 없다. * - * 미디어만 규칙이 다르다 — [MemorialWritePayload] 안에서는 빈 배열이 아니라 **명시적 JSON null** 이 - * 삭제다 (#1596). 그쪽 계약은 해당 DTO KDoc 에 적혀 있다. + * **판정 단위는 서버가 반영하는 단위와 같아야 한다.** 객체를 통째로 「바뀌었다/아니다」로 재면, 그 + * 객체 안에서 안 건드린 형제 슬롯이 낡은 값째 함께 실려 나간다. 그래서 [credentials] 는 id·비밀번호를 + * 따로 재고(서버도 `CredentialsRelationSupport.update` 가 둘을 독립으로 갱신한다), [memorial] 은 + * 슬롯별 [FieldPatch] 를 든 [MemorialPatchPayload] 로 말한다 (#1617). */ data class AfternoteUpdatePayload( val type: AfternoteType, @@ -38,9 +40,16 @@ data class AfternoteUpdatePayload( val leaveMessageBlocks: List? = null, val credentials: AfternoteAccountCredentials? = null, val receivers: List? = null, - val memorial: MemorialWritePayload? = null, + val memorial: MemorialPatchPayload? = null, ) +/** + * 계정 정보. **수정에서 `null` 슬롯은 「안 건드림」이라 요청에서 빠진다.** + * + * 서버가 id 와 비밀번호를 각각 `!= null` 일 때만 갈아 끼우므로(`CredentialsRelationSupport.update`), + * 한쪽만 실어 보내는 것이 곧 「나머지는 그대로 두라」가 된다. 둘 다 싣는 편이 안전해 보이지만 + * 반대다 — 안 고친 쪽이 낡은 값으로 남의 변경을 되돌린다 (#1617). + */ data class AfternoteAccountCredentials( val id: String? = null, val password: String? = null, diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/FieldPatch.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/FieldPatch.kt new file mode 100644 index 0000000000..cdc2ff632c --- /dev/null +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/FieldPatch.kt @@ -0,0 +1,41 @@ +package com.afternote.feature.afternote.domain.model.author + +/** + * 부분 갱신에서 한 슬롯의 세 가지 상태를 가르는 값 (#1617). + * + * `null` 하나로는 「안 건드렸다」와 「비워 달라」를 구분할 수 없다. 서버가 그 둘을 실제로 다르게 + * 처리하는 자리 — 애프터노트 플레이리스트의 미디어 슬롯 — 에서는 반드시 이 타입으로 말해야 한다. + * + * | 상태 | 뜻 | wire | + * |---|---|---| + * | [Unchanged] | 사용자가 만지지 않았다 | 키 자체가 나가지 않는다 | + * | [Set] 에 `null` | 지워 달라 | 키를 달고 JSON `null` 이 나간다 | + * | [Set] 에 값 | 이 값으로 바꿔 달라 | 값이 나간다 | + * + * 서버는 `PlaylistRequestDeserializer` 의 `node.has(...)` 로 키 유무를 먼저 보고, + * `AfternotePlaylist.update` 가 그 specified 플래그로 「유지」와 「삭제」를 가른다 + * (Afternote-BE `bbff47c`). + * + * 컬렉션 슬롯에는 쓰지 않는다 — 곡·수신자·처리 방법은 `null`(안 건드림)과 빈 배열(전부 삭제)로 + * 이미 두 뜻을 다 말할 수 있다. 서버도 `songs != null` 여부만 보고 통째로 갈아 끼운다. + */ +sealed interface FieldPatch { + /** 만지지 않았다 — 요청에서 키째 뺀다. */ + data object Unchanged : FieldPatch + + /** 이 값으로 바꾼다. [value] 가 `null` 이면 삭제 지시다. */ + data class Set( + val value: T, + ) : FieldPatch + + companion object { + /** + * [current] 가 [baseline] 과 다를 때만 [Set] 으로 싣는다. + * 안 건드린 슬롯을 실어 남의 변경을 덮는 일을 이 한 줄로 막는다. + */ + fun changedOrUnchanged( + current: T, + baseline: T, + ): FieldPatch = if (current == baseline) Unchanged else Set(current) + } +} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/MemorialWritePayload.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/MemorialWritePayload.kt index 61658f1b3a..204a4f0182 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/MemorialWritePayload.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/MemorialWritePayload.kt @@ -31,6 +31,31 @@ data class MemorialVideoPayload( val thumbnailUrl: String? = null, ) +/** + * 추억 노트 미디어·곡의 **부분 수정** 지시 (#1617). + * + * [MemorialWritePayload] 와 결정적으로 다르다 — 저쪽은 「지금 이 노트가 가져야 할 상태」를 통째로 + * 말하고(생성), 이쪽은 「내가 만진 슬롯만」 말한다(수정). 수정에서 전체 스냅샷을 실으면, 에디터를 + * 연 뒤 서버가 바뀐 경우 만진 적 없는 슬롯이 낡은 값으로 덮인다 — 곡만 바꾼 저장이 그 사이 올라온 + * 영정사진·추모 영상을 명시적 `null` 로 지우는 사고가 그것이다. + * + * 슬롯마다 표현이 다른 것은 서버 계약이 다르기 때문이다: + * - [memorialPhotoUrl]·[memorialVideo] 는 **키 유무**로 유지/삭제를 가르므로 [FieldPatch] 가 필요하다. + * - [songs] 는 서버가 `songs != null` 만 보므로 `null`(안 건드림)과 빈 배열(전부 삭제)로 충분하다. + */ +data class MemorialPatchPayload( + val memorialPhotoUrl: FieldPatch = FieldPatch.Unchanged, + val songs: List? = null, + val memorialVideo: FieldPatch = FieldPatch.Unchanged, +) { + /** 어느 슬롯도 만지지 않았으면 `playlist` 키 자체를 내보내지 않아야 한다. */ + val isUnchanged: Boolean + get() = + memorialPhotoUrl is FieldPatch.Unchanged && + songs == null && + memorialVideo is FieldPatch.Unchanged +} + data class MemorialSongPayload( val title: String, val artist: String, diff --git a/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt b/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt index f9783ffb2a..c62a25f763 100644 --- a/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt +++ b/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt @@ -12,7 +12,9 @@ import com.afternote.feature.afternote.domain.model.author.Detail import com.afternote.feature.afternote.domain.model.author.DetailContent import com.afternote.feature.afternote.domain.model.author.DetailCredentials import com.afternote.feature.afternote.domain.model.author.DetailReceiver +import com.afternote.feature.afternote.domain.model.author.FieldPatch import com.afternote.feature.afternote.domain.model.author.ListItem +import com.afternote.feature.afternote.domain.model.author.MemorialVideoPayload 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 @@ -221,13 +223,20 @@ private fun DetailContent.updatedWith(payload: AfternoteUpdatePayload): DetailCo if (memorial == null) { previous ?: MemorialDetail(emptyList(), MemorialMedia(null, null, null)) } else { + // 서버와 같이 **슬롯별로** 반영한다 — 만지지 않은 슬롯(FieldPatch.Unchanged·songs null)은 + // 기존 값을 남긴다. 여기서 통째로 갈아 끼우면 부분 PATCH 의 결함이 테스트에 안 잡힌다 (#1617). + val previousMedia = previous?.media + val video = memorial.memorialVideo.resolve(previousMedia?.toVideoPayload()) MemorialDetail( - songs = memorial.songs.map { DetailSong(it.title, it.artist, it.coverUrl) }, + songs = + memorial.songs + ?.map { DetailSong(it.title, it.artist, it.coverUrl) } + ?: previous?.songs.orEmpty(), media = MemorialMedia( - photoUrl = memorial.memorialPhotoUrl, - videoUrl = memorial.memorialVideo?.videoUrl, - thumbnailUrl = memorial.memorialVideo?.thumbnailUrl, + photoUrl = memorial.memorialPhotoUrl.resolve(previousMedia?.photoUrl), + videoUrl = video?.videoUrl, + thumbnailUrl = video?.thumbnailUrl, ), ) }, @@ -244,3 +253,13 @@ private fun AfternoteAccountCredentials?.toDetailCredentials(previous: DetailCre id = this?.id ?: previous?.id.orEmpty(), password = this?.password ?: previous?.password.orEmpty(), ) + +/** 만지지 않은 슬롯은 기존 값을 남긴다 — 서버 `AfternotePlaylist.update` 의 specified 플래그와 같은 규칙. */ +private fun FieldPatch.resolve(previous: T): T = + when (this) { + is FieldPatch.Unchanged -> previous + is FieldPatch.Set -> value + } + +private fun MemorialMedia.toVideoPayload(): MemorialVideoPayload? = + videoUrl?.let { MemorialVideoPayload(videoUrl = it, thumbnailUrl = thumbnailUrl) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt index 383ebbc95d..cc48b5ed7c 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorFormMapper.kt @@ -11,11 +11,13 @@ import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload import com.afternote.feature.afternote.domain.model.author.Detail import com.afternote.feature.afternote.domain.model.author.DetailContent import com.afternote.feature.afternote.domain.model.author.DetailCredentials +import com.afternote.feature.afternote.domain.model.author.FieldPatch +import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload 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 -import com.afternote.feature.afternote.presentation.author.editor.AfternoteEditorFormMapper.buildUpdatePayload +import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia import com.afternote.feature.afternote.presentation.author.editor.memorial.playlist.Song import com.afternote.feature.afternote.presentation.author.editor.message.EditorMessageTextBlock import com.afternote.feature.afternote.presentation.author.editor.model.EditorContentPrefill @@ -220,15 +222,19 @@ internal object AfternoteEditorFormMapper { ) /** - * 수정 요청 페이로드를 조립한다 — **[baseline] 과 달라진 필드만 싣는다** (#1617). + * 수정 요청 페이로드를 조립한다 — **[baseline] 과 달라진 슬롯만 싣는다** (#1617). * * 폼 전체 스냅샷을 매번 통째로 보내면, 에디터를 연 뒤 서버가 바뀐 경우 사용자가 만진 적도 없는 - * 필드가 낡은 값으로 덮인다. 서버는 「키 없음 = 유지」로 읽으므로, 안 건드린 필드를 아예 빼는 + * 필드가 낡은 값으로 덮인다. 서버는 「키 없음 = 유지」로 읽으므로, 안 건드린 슬롯을 아예 빼는 * 것으로 그 사고를 구조적으로 없앤다. * - * @param baseline 수정 진입 시 받은 상세 응답을 [buildUpdateBaseline] 로 옮긴 **원본 스냅샷**. - * `null`(상세를 아직/끝내 못 받음)이면 비교할 기준이 없으므로 종전처럼 전량을 싣는다 — - * 덜 보내다 사용자의 편집을 잃는 것보다 낫다. + * **판정 단위는 서버가 반영하는 단위와 같다.** 객체를 통째로 재면 그 안에서 안 건드린 형제 + * 슬롯이 낡은 값째 딸려 나가므로, 계정 정보는 id·비밀번호를 따로, 플레이리스트는 사진·영상·곡을 + * 따로 잰다. + * + * [baseline] 은 **필수**다. 기준 없이 조립할 수 있게 두면 상세 조회가 실패한 화면에서 빈 폼이 + * 그대로 전량 PATCH 로 나가 이 이슈가 잡으려던 삭제 사고를 되풀이한다. 기준이 없을 때 저장을 + * 막는 것은 호출부([AfternoteEditorViewModel])의 몫이다. */ fun buildUpdatePayload( type: AfternoteType, @@ -236,47 +242,75 @@ internal object AfternoteEditorFormMapper { selectedReceiverIds: List, playlistSongs: List, memorialMedia: MemorialMediaUrls, - baseline: AfternoteUpdatePayload?, + baseline: AfternoteEditorSnapshot, ): AfternoteUpdatePayload { - val full = - when (type) { - AfternoteType.MEMORIAL -> { - AfternoteUpdatePayload( - type = AfternoteType.MEMORIAL, - title = payload.serviceName, - leaveMessageBlocks = payload.messageBlocks.toLeaveMessageBlocks(), - memorial = - buildMemorialWritePayload( - playlistSongs = playlistSongs, - memorialPhotoUrl = memorialMedia.memorialPhotoUrl, - memorialVideoUrl = memorialMedia.memorialVideoUrl, - memorialThumbnailUrl = memorialMedia.memorialThumbnailUrl, - ), - ) - } + if (type == AfternoteType.ESTATE) { + // placeholder 카테고리는 Validator 에서 차단됨. 도달 시 호출자 버그. + error("Unimplemented type cannot be saved: $type") + } + val current = + buildEditorSnapshot( + type = type, + payload = payload, + selectedReceiverIds = selectedReceiverIds, + playlistSongs = playlistSongs, + memorialMedia = memorialMedia, + ) + return AfternoteUpdatePayload( + type = type, + title = current.title.takeIf { it.trim() != baseline.title.trim() }, + // 빈 문자열을 걷어내지 않는다 — 서버는 actions 원소를 검증 없이 저장하므로 `[""]` 가 + // 실제로 남아 있을 수 있고, 그 행을 지운 저장이 양쪽 정규화로 상쇄되면 삭제가 사라진다. + processingMethods = current.processingMethods.takeIf { it != baseline.processingMethods }, + leaveMessageBlocks = current.leaveMessageBlocks.takeIf { it != baseline.leaveMessageBlocks }, + credentials = diffCredentials(current, baseline), + // 순서는 뜻을 갖지 않으므로 정렬해 견주고, 실을 때는 폼 순서 그대로 보낸다. + receivers = + current.receiverIds + ?.takeIf { it.sorted() != baseline.receiverIds?.sorted() } + ?.map { ReceiverRefPayload(receiverId = it) }, + memorial = diffMemorial(current, baseline), + ) + } - AfternoteType.GALLERY_AND_FILES, AfternoteType.SOCIAL_NETWORK, AfternoteType.BUSINESS -> { - buildProcessingMethodsUpdatePayload(type, payload, selectedReceiverIds) - } + /** 계정 정보는 서버가 id·비밀번호를 독립으로 갱신하므로 슬롯별로 재고, 둘 다 그대로면 통째로 뺀다. */ + private fun diffCredentials( + current: AfternoteEditorSnapshot, + baseline: AfternoteEditorSnapshot, + ): AfternoteAccountCredentials? { + val id = current.credentialsId.takeIf { it != baseline.credentialsId } + val password = current.credentialsPassword.takeIf { it != baseline.credentialsPassword } + return if (id == null && password == null) null else AfternoteAccountCredentials(id = id, password = password) + } - // placeholder 카테고리는 Validator 에서 차단됨. 도달 시 호출자 버그. - AfternoteType.ESTATE -> { - error("Unimplemented type cannot be saved: $type") - } - } - return full.omittingFieldsUnchangedFrom(baseline) + /** 플레이리스트는 슬롯 셋을 따로 재고, 하나도 안 바뀌었으면 `playlist` 키 자체를 내보내지 않는다. */ + private fun diffMemorial( + current: AfternoteEditorSnapshot, + baseline: AfternoteEditorSnapshot, + ): MemorialPatchPayload? { + if (current.type != AfternoteType.MEMORIAL) return null + val patch = + MemorialPatchPayload( + memorialPhotoUrl = + FieldPatch.changedOrUnchanged(current.memorialPhotoUrl, baseline.memorialPhotoUrl), + songs = current.songs.takeIf { it != baseline.songs }, + memorialVideo = + FieldPatch.changedOrUnchanged(current.memorialVideo, baseline.memorialVideo), + ) + return patch.takeUnless { it.isUnchanged } } /** - * 수정 진입 시 받은 상세를 **「서버가 지금 들고 있는 값」 그대로의 수정 페이로드**로 옮긴다. + * 수정 진입 시 받은 상세를 **「서버가 지금 들고 있는 값」** 스냅샷으로 옮긴다. * - * [buildUpdatePayload] 가 만드는 현재 폼 페이로드와 **같은 어휘**라, 필드끼리 바로 견줄 수 있다. + * [buildEditorSnapshot] 이 만드는 현재 폼 스냅샷과 같은 어휘라 슬롯끼리 바로 견줄 수 있다. * 프리필([buildEditorFormPrefill])을 거치지 않고 [Detail] 에서 직접 만드는 이유는, 프리필이 * 화면 표시용으로 값을 한 번 가공하기 때문이다 — 비교 기준은 가공 전 원본이어야 한다. */ - fun buildUpdateBaseline(detail: Detail): AfternoteUpdatePayload { + fun buildUpdateBaseline(detail: Detail): AfternoteEditorSnapshot { val content = detail.content - return AfternoteUpdatePayload( + val memorial = (content as? DetailContent.Memorial)?.memorial + return AfternoteEditorSnapshot( type = content.type, title = detail.serviceName, processingMethods = @@ -286,143 +320,108 @@ internal object AfternoteEditorFormMapper { is DetailContent.Gallery -> content.processingMethods is DetailContent.Memorial, DetailContent.Estate -> null }, - leaveMessageBlocks = detail.leaveMessageBlocks, - credentials = - when (content) { - is DetailContent.SocialNetwork -> content.credentials.toAccountCredentials() - is DetailContent.Business -> content.credentials.toAccountCredentials() - is DetailContent.Gallery, is DetailContent.Memorial, DetailContent.Estate -> null - }, - // 추억 노트 수정 페이로드는 수신자를 싣지 않으므로([buildUpdatePayload] 의 MEMORIAL 분기) - // 기준도 같은 자리를 비워 둔다 — 그래야 「양쪽 다 없음」으로 맞아떨어진다. - receivers = + leaveMessageBlocks = detail.leaveMessageBlocks.normalizedForDiff(), + credentialsId = content.detailCredentials()?.id?.ifBlank { null }, + credentialsPassword = content.detailCredentials()?.password?.ifBlank { null }, + // 추억 노트 수정 페이로드는 수신자를 싣지 않으므로 기준도 같은 자리를 비워 둔다. + receiverIds = when (content) { is DetailContent.Memorial, DetailContent.Estate -> { null } is DetailContent.SocialNetwork, is DetailContent.Business, is DetailContent.Gallery -> { - detail.receivers.map { ReceiverRefPayload(receiverId = it.receiverId) } + detail.receivers.map { it.receiverId } } }, - memorial = - (content as? DetailContent.Memorial)?.let { memorialContent -> - MemorialWritePayload( - memorialPhotoUrl = memorialContent.memorial.media.photoUrl, - songs = - memorialContent.memorial.songs.map { song -> - MemorialSongPayload( - title = song.title, - artist = song.artist, - coverUrl = song.coverUrl, - ) - }, - memorialVideo = - memorialContent.memorial.media.videoUrl?.ifBlank { null }?.let { url -> - MemorialVideoPayload( - videoUrl = url, - thumbnailUrl = - memorialContent.memorial.media.thumbnailUrl - ?.ifBlank { null }, - ) - }, - ) + memorialPhotoUrl = memorial?.media?.photoUrl?.ifBlank { null }, + memorialVideo = memorial?.media?.toVideoPayload(), + songs = + memorial?.songs?.map { song -> + MemorialSongPayload(title = song.title, artist = song.artist, coverUrl = song.coverUrl) }, ) } - private fun DetailCredentials.toAccountCredentials() = - AfternoteAccountCredentials( - id = id.ifBlank { null }, - password = password.ifBlank { null }, - ) - - /** - * 처리 방법 기반 카테고리(SOCIAL·BUSINESS·GALLERY) 공용 update payload — - * [AfternoteUpdatePayload.processingMethods] 를 채우고 계정형(SOCIAL·BUSINESS)만 credentials 를 싣는다. - * MEMORIAL 은 [AfternoteUpdatePayload.memorial] 기반이라 [buildUpdatePayload] 의 별도 분기. - */ - private fun buildProcessingMethodsUpdatePayload( + /** 현재 폼을 기준 스냅샷과 **같은 어휘**로 옮긴다 — 그래야 슬롯끼리 견줄 수 있다. */ + private fun buildEditorSnapshot( type: AfternoteType, payload: RegisterAfternotePayload, selectedReceiverIds: List, - ): AfternoteUpdatePayload { - val processingMethods = payload.processingMethods + playlistSongs: List, + memorialMedia: MemorialMediaUrls, + ): AfternoteEditorSnapshot { + val isMemorial = type == AfternoteType.MEMORIAL val hasCredentials = type == AfternoteType.SOCIAL_NETWORK || type == AfternoteType.BUSINESS - val credentials = - if (hasCredentials) { - val id = payload.accountId.ifBlank { null } - val pw = payload.password.ifBlank { null } - if (id != null || pw != null) AfternoteAccountCredentials(id = id, password = pw) else null - } else { - null - } - return AfternoteUpdatePayload( + return AfternoteEditorSnapshot( type = type, title = payload.serviceName, - // 빈 목록을 null 로 접지 않는다 — 「전부 지웠다」와 「안 건드렸다」는 다른 지시이고, - // 후자는 이제 baseline 비교가 판정한다 (#1617). - processingMethods = processingMethods, - leaveMessageBlocks = payload.messageBlocks.toLeaveMessageBlocks(), - credentials = credentials, - receivers = selectedReceiverIds.map { ReceiverRefPayload(receiverId = it) }, - memorial = null, + processingMethods = if (isMemorial) null else payload.processingMethods, + leaveMessageBlocks = payload.messageBlocks.toLeaveMessageBlocks().normalizedForDiff(), + credentialsId = if (hasCredentials) payload.accountId.ifBlank { null } else null, + credentialsPassword = if (hasCredentials) payload.password.ifBlank { null } else null, + receiverIds = if (isMemorial) null else selectedReceiverIds, + memorialPhotoUrl = if (isMemorial) memorialMedia.memorialPhotoUrl?.ifBlank { null } else null, + memorialVideo = if (isMemorial) memorialMedia.toVideoPayload() else null, + songs = + if (isMemorial) { + playlistSongs.map { song -> + MemorialSongPayload(title = song.title, artist = song.artist, coverUrl = song.albumCoverUrl) + } + } else { + null + }, ) } + + private fun DetailContent.detailCredentials(): DetailCredentials? = + when (this) { + is DetailContent.SocialNetwork -> credentials + is DetailContent.Business -> credentials + is DetailContent.Gallery, is DetailContent.Memorial, DetailContent.Estate -> null + } + + private fun MemorialMedia.toVideoPayload(): MemorialVideoPayload? = + videoUrl?.ifBlank { null }?.let { url -> + MemorialVideoPayload(videoUrl = url, thumbnailUrl = thumbnailUrl?.ifBlank { null }) + } + + private fun MemorialMediaUrls.toVideoPayload(): MemorialVideoPayload? = + memorialVideoUrl?.ifBlank { null }?.let { url -> + MemorialVideoPayload(videoUrl = url, thumbnailUrl = memorialThumbnailUrl?.ifBlank { null }) + } } /** - * [baseline] 과 값이 같은 필드를 `null` 로 떨어뜨려 요청에서 키째 빠지게 한다 (#1617). - * - * **필드를 빼기만 한다 — 채우지 않는다.** 기준이 없으면([baseline] 이 `null`) 손대지 않고 전량을 - * 그대로 돌려준다. + * 현재 폼과 서버 원본을 견주기 위한 **같은 어휘의 스냅샷** (#1617). * - * 비교는 [normalizedForDiff] 를 거친 값끼리 한다. 서버가 준 원본과 폼을 왕복한 값은 공백·빈 문자열· - * 정렬에서 어긋날 수 있고, 그 차이를 「사용자가 고쳤다」로 읽으면 안 건드린 필드가 다시 실린다. + * 「무엇을 보낼까」가 아니라 「무엇이 달라졌나」만 판정하는 자료다. 그래서 wire 표현(키 생략·명시적 + * null·빈 배열)을 담지 않고, 슬롯마다 의미값 하나씩만 든다. 카테고리에 없는 슬롯은 `null` 로 비워 + * 양쪽이 「둘 다 없음」으로 맞아떨어지게 한다. */ -private fun AfternoteUpdatePayload.omittingFieldsUnchangedFrom(baseline: AfternoteUpdatePayload?): AfternoteUpdatePayload { - if (baseline == null) return this - val current = normalizedForDiff() - val original = baseline.normalizedForDiff() - return copy( - title = title.takeIf { current.title != original.title }, - processingMethods = processingMethods.takeIf { current.processingMethods != original.processingMethods }, - leaveMessageBlocks = leaveMessageBlocks.takeIf { current.leaveMessageBlocks != original.leaveMessageBlocks }, - credentials = credentials.takeIf { current.credentials != original.credentials }, - receivers = receivers.takeIf { current.receivers != original.receivers }, - memorial = memorial.takeIf { current.memorial != original.memorial }, - ) -} +internal data class AfternoteEditorSnapshot( + val type: AfternoteType, + val title: String, + val processingMethods: List?, + val leaveMessageBlocks: List, + val credentialsId: String?, + val credentialsPassword: String?, + val receiverIds: List?, + val memorialPhotoUrl: String?, + val memorialVideo: MemorialVideoPayload?, + val songs: List?, +) /** - * 「달라졌는가」만 판정하기 위한 정규형 — **전송값이 아니다.** + * 남기실 말씀을 비교용 정규형으로 좁힌다. * - * 서버 원본과 폼 왕복본이 뜻은 같은데 표기만 다른 경우를 흡수한다: 앞뒤 공백, 빈 문자열과 `null`, - * 그리고 뜻이 없는 정렬(수신자는 순서가 의미를 갖지 않는다 — 처리 방법·곡은 사용자가 정한 순서라 - * 정렬하지 않는다). + * 폼에서 도메인으로 옮길 때 이미 앞뒤 공백을 떼므로([toLeaveMessageBlocks]), 서버 원본도 같은 모양으로 + * 맞춰야 「공백만 다른 같은 값」이 변경으로 잡히지 않는다. 본문이 빈 블록은 서버가 400 으로 거절하고 + * (`AfternoteValidationCommons.validateLeaveMessage`) 응답 파싱도 걸러 내므로 양쪽에 존재하지 않는다 — + * 그래서 여기서 원소를 **버리지는 않는다.** 버리기 시작하면 「지웠다」가 상쇄돼 사라진다. */ -private fun AfternoteUpdatePayload.normalizedForDiff(): AfternoteUpdatePayload = - copy( - title = title?.trim(), - processingMethods = processingMethods?.map { it.trim() }?.filter { it.isNotEmpty() }, - leaveMessageBlocks = - leaveMessageBlocks - ?.map { LeaveMessageBlock(title = it.title?.trim()?.ifEmpty { null }, body = it.body.trim()) } - ?.filter { it.body.isNotEmpty() }, - credentials = - credentials - ?.let { AfternoteAccountCredentials(id = it.id?.ifBlank { null }, password = it.password?.ifBlank { null }) } - ?.takeIf { it.id != null || it.password != null }, - receivers = receivers?.sortedBy { it.receiverId }, - memorial = - memorial?.let { media -> - MemorialWritePayload( - memorialPhotoUrl = media.memorialPhotoUrl?.ifBlank { null }, - songs = media.songs, - memorialVideo = media.memorialVideo?.takeIf { !it.videoUrl.isNullOrBlank() }, - ) - }, - ) +private fun List.normalizedForDiff(): List = + map { LeaveMessageBlock(title = it.title?.trim()?.ifEmpty { null }, body = it.body.trim()) } private fun LeaveMessageBlock.toEditorBlock(): EditorMessageTextBlock = EditorMessageTextBlock( diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorRouteHelpers.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorRouteHelpers.kt index 6ae9cec72c..2868113a2e 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorRouteHelpers.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorRouteHelpers.kt @@ -27,6 +27,10 @@ internal fun AfternoteEditorError.messageResId(): Int = R.string.afternote_editor_receiver_selection_unavailable } + AfternoteEditorError.PrefillUnavailable -> { + R.string.afternote_editor_prefill_unavailable + } + is AfternoteEditorError.Upload -> { when (target) { AfternoteEditorError.Upload.Target.THUMBNAIL -> R.string.afternote_editor_thumbnail_upload_failed diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt index 190460e15e..6d76c05f67 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt @@ -10,7 +10,6 @@ import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.error.AfternoteFailure -import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository @@ -445,6 +444,14 @@ class AfternoteEditorViewModel val typeForSave = if (editingId != null) (editorState.originalType ?: type) else type + // 수정인데 기준 스냅샷이 없다 = 상세를 못 받았다. 이 상태로 보내면 「안 건드림」과 + // 「전부 지움」을 가릴 수 없어 빈 폼이 그대로 삭제 지시가 된다 (#1617). + val updateBaseline = editorState.updateBaseline + if (editingId != null && updateBaseline == null) { + internalState.update { it.withError(AfternoteEditorError.PrefillUnavailable) } + return + } + viewModelScope.launch { internalState.update { it.copy(isSaving = true, errorEvent = null) @@ -468,7 +475,7 @@ class AfternoteEditorViewModel selectedReceiverIds = selectedReceiverIds, playlistSongs = playlistSongs, memorialMedia = memorialMediaForSave, - updateBaseline = editorState.updateBaseline, + updateBaseline = updateBaseline, ).fold( onSuccess = { command -> executeSaveCommand(command).fold( @@ -529,7 +536,7 @@ class AfternoteEditorViewModel selectedReceiverIds: List, playlistSongs: List, memorialMedia: SaveAfternoteMemorialMedia, - updateBaseline: AfternoteUpdatePayload?, + updateBaseline: AfternoteEditorSnapshot?, ): Result { val resolved = resolveMemorialMediaForSave( @@ -555,7 +562,11 @@ class AfternoteEditorViewModel memorialThumbnailUrl = memorialMedia.memorialVideo.displayed?.thumbnailUrl, memorialPhotoUrl = resolved.resolvedMemorialPhotoUrl, ), - baseline = updateBaseline, + // saveAfternote 가 기준 없는 수정을 이미 막았다 — 여기 도달하면 반드시 있다. + baseline = + checkNotNull(updateBaseline) { + "수정 저장에 기준 스냅샷이 없다 — saveAfternote 의 가드가 빠졌다" + }, ) SaveAfternoteCommand.Update(id = editingId, payload = updatePayload) } else { @@ -665,7 +676,7 @@ class AfternoteEditorViewModel * 저장 시 현재 폼과 견줘 **달라진 필드만** 요청에 싣는 기준이다. `null` 이면 기준이 없다는 * 뜻이라(신규 작성이거나 상세 로드 실패) 종전처럼 전량을 싣는다. */ - val updateBaseline: AfternoteUpdatePayload? = null, + val updateBaseline: AfternoteEditorSnapshot? = null, ) private fun InternalState.toUiState(): AfternoteEditorUiState = diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteSaveState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteSaveState.kt index 0b9ff005cc..ed4be18290 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteSaveState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteSaveState.kt @@ -48,6 +48,15 @@ sealed interface AfternoteEditorError { */ data object ReceiverSelectionUnavailable : AfternoteEditorError + /** + * 수정 대상의 원본을 못 받아 **무엇이 바뀌었는지 판정할 수 없음** (#1617). + * + * 상세 조회가 실패하면 폼이 비어 있는 채로 열린다. 그 상태로 저장하면 「안 건드림」과 「전부 + * 지움」을 가릴 기준이 없어, 처리 방법·수신자·남기실 말씀이 빈 값으로 나가 서버 내용을 지운다. + * 저장 실패가 아니라 **저장을 시작하면 안 되는 상태**라, 사용자에게 다시 불러오라고 알린다. + */ + data object PrefillUnavailable : AfternoteEditorError + data class Upload( val target: Target, ) : AfternoteEditorError { diff --git a/feature/afternote/presentation/src/main/res/values/strings.xml b/feature/afternote/presentation/src/main/res/values/strings.xml index 661ba3451e..3ebcd515a6 100644 --- a/feature/afternote/presentation/src/main/res/values/strings.xml +++ b/feature/afternote/presentation/src/main/res/values/strings.xml @@ -152,6 +152,7 @@ 저장에 실패했습니다. 검색 실패 수신자 목록을 불러오지 못해 선택한 수신자를 반영하지 못했습니다. 잠시 후 다시 선택해 주세요. + 기존 내용을 불러오지 못해 저장할 수 없습니다. 화면을 나갔다 다시 들어와 주세요. 영상 미리보기를 올리지 못했습니다. 영상에서 미리보기를 만들지 못했습니다. 다시 시도 diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt index 3b28bb7d0d..3127e2ec8a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt @@ -84,8 +84,8 @@ class AfternoteAuthorExtendedTest { * 상세 → 수정 → 저장을 실제 화면으로 완주하며, **사용자가 만진 필드만** 요청에 실리는지 본다 (#1617). * * 여기서 손대는 것은 계정 ID 와 처리 방법 둘뿐이다. 제목·남기실 말씀·수신자는 프리필된 채로 - * 두므로 페이로드에서 빠져야 한다 — 종전에는 폼 스냅샷이 통째로 실려, 에디터를 연 뒤 서버가 - * 바뀌면 만진 적 없는 필드가 낡은 값으로 덮였다. + * 두므로 페이로드에서 빠져야 하고, **계정 정보 안에서도 안 고친 비밀번호는 빠져야 한다** — + * 종전에는 폼 스냅샷이 통째로 실려, 에디터를 연 뒤 서버가 바뀌면 만진 적 없는 값이 덮였다. */ @Test fun detailEdit_sendsOnlyTouchedFieldsThroughEditorRoute() { @@ -145,10 +145,12 @@ class AfternoteAuthorExtendedTest { val (updatedId, payload) = repository.updateCalls.single() assertEquals(73L, updatedId) assertEquals(AfternoteType.SOCIAL_NETWORK, payload.type) - // 만진 둘 — 처리 방법과 계정 ID. 계정은 한 덩어리라 안 고친 비밀번호도 함께 실린다. + // 만진 둘 — 처리 방법과 계정 ID. assertEquals(listOf("계정 보존"), payload.processingMethods) assertEquals("edited@example.test", payload.credentials?.id) - assertEquals("old-password", payload.credentials?.password) + // 계정 정보 안에서도 슬롯별로 갈린다: 서버가 id·비밀번호를 따로 갱신하므로, 안 고친 + // 비밀번호를 함께 실으면 그 사이 다른 기기가 바꾼 값이 낡은 값으로 되돌아간다 (#1617). + assertNull("비밀번호는 고친 적이 없다", payload.credentials?.password) // 안 만진 나머지 — 키째 빠져야 서버가 기존 값을 유지한다. assertNull("제목을 고친 적이 없다", payload.title) assertNull("남기실 말씀을 고친 적이 없다", payload.leaveMessageBlocks) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorMissingBaselineTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorMissingBaselineTest.kt new file mode 100644 index 0000000000..952e9468a1 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorMissingBaselineTest.kt @@ -0,0 +1,162 @@ +package com.afternote.feature.afternote.presentation.author.editor + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.testing.FakeUserRepository +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 +import com.afternote.feature.afternote.domain.model.author.DetailTimestamps +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.author.editor.model.RegisterAfternotePayload +import com.afternote.feature.afternote.presentation.author.editor.state.AfternoteEditorError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +/** + * 기준 스냅샷 없이 수정 저장이 나가지 않는지에 대한 회귀 가드 (#1617). + * + * 상세 조회가 실패하면 폼이 **비어 있는 채로** 열린다. 그 상태에서 저장하면 「안 건드림」과 「전부 + * 지움」을 가릴 기준이 없어, 빈 처리 방법·수신자·남기실 말씀이 그대로 삭제 지시로 나간다 — 이 이슈가 + * 잡으려던 바로 그 사고다. 게다가 제목만 채우면 GALLERY·MEMORIAL 은 Validator 도 통과한다. + * + * 그래서 저장을 **시작하지 않고** 사용자에게 다시 불러오라고 알린다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteEditorMissingBaselineTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `상세를 못 받은 채 저장하면 요청을 보내지 않고 알린다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.failure(IOException("상세 조회 실패")) } + } + val viewModel = viewModel(repository) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + viewModel.saveAfternote( + payload = RegisterAfternotePayload(serviceName = "구글 포토", date = "2026-08-30"), + selectedReceiverIds = emptyList(), + memorialMedia = SaveAfternoteMemorialMedia(), + ) + runCurrent() + + assertTrue("기준 없는 수정은 서버로 나가면 안 된다", repository.updateCalls.isEmpty()) + assertEquals( + AfternoteEditorError.PrefillUnavailable, + viewModel.uiState.value.error, + ) + } + + @Test + fun `상세를 받았으면 저장이 정상으로 나간다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.success(galleryDetail()) } + onUpdate = { id, _ -> Result.success(id) } + } + val viewModel = viewModel(repository) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + viewModel.saveAfternote( + payload = RegisterAfternotePayload(serviceName = "새 제목", date = "2026-08-30"), + selectedReceiverIds = emptyList(), + memorialMedia = SaveAfternoteMemorialMedia(), + ) + runCurrent() + + assertEquals(1, repository.updateCalls.size) + assertEquals( + "새 제목", + repository.updateCalls + .single() + .second.title, + ) + } + + /** 저장 차단 안내는 저장 실패 문구와 달라야 한다 — 사용자가 할 일이 다르다. */ + @Test + fun `기준 없음은 저장 실패와 다른 문구로 안내한다`() { + assertEquals( + R.string.afternote_editor_prefill_unavailable, + AfternoteEditorError.PrefillUnavailable.messageResId(), + ) + } + + private fun galleryDetail() = + Detail( + id = EDIT_ID, + serviceName = "구글 포토", + timestamps = DetailTimestamps(updatedAt = "2026-08-30"), + receivers = emptyList(), + leaveMessageBlocks = emptyList(), + content = DetailContent.Gallery(processingMethods = listOf("파일 전달")), + ) + + private fun viewModel(afternoteRepository: FakeAfternoteRepository): AfternoteEditorViewModel = + AfternoteEditorViewModel( + savedStateHandle = + SavedStateHandle( + mapOf( + "initialType" to AfternoteType.GALLERY_AND_FILES, + "itemId" to EDIT_ID, + ), + ), + userRepository = FakeUserRepository.strict(), + afternoteRepository = afternoteRepository, + memorialThumbnailUploadRepository = + MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, + resolveMemorialMediaForSave = + ResolveMemorialMediaForSaveUseCase( + MemorialMediaUploadRepository { _, _ -> Result.success(null) }, + ), + errorReporter = NoopErrorReporter, + ) + + private object NoopErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit + } + + private companion object { + const val EDIT_ID = 73L + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorPartialUpdateTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorPartialUpdateTest.kt index d748bfed64..1b8ba3f367 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorPartialUpdateTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorPartialUpdateTest.kt @@ -2,12 +2,14 @@ package com.afternote.feature.afternote.presentation.author.editor import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock -import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload import com.afternote.feature.afternote.domain.model.author.Detail import com.afternote.feature.afternote.domain.model.author.DetailContent import com.afternote.feature.afternote.domain.model.author.DetailCredentials import com.afternote.feature.afternote.domain.model.author.DetailReceiver import com.afternote.feature.afternote.domain.model.author.DetailTimestamps +import com.afternote.feature.afternote.domain.model.author.FieldPatch +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.ReceiverRefPayload import com.afternote.feature.afternote.domain.model.author.playlist.DetailSong import com.afternote.feature.afternote.domain.model.author.playlist.MemorialDetail @@ -16,22 +18,21 @@ import com.afternote.feature.afternote.presentation.author.editor.memorial.playl import com.afternote.feature.afternote.presentation.author.editor.message.EditorMessageTextBlock import com.afternote.feature.afternote.presentation.author.editor.model.RegisterAfternotePayload import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** - * 수정 요청이 **사용자가 만진 필드만** 싣는지 고정한다 (#1617). + * 수정 요청이 **사용자가 만진 슬롯만** 싣는지 고정한다 (#1617). * * 종전에는 폼이 든 전체 스냅샷이 매번 통째로 나갔다. 에디터를 연 뒤 서버가 바뀌면, 사용자가 건드린 - * 적도 없는 필드가 낡은 로컬 값으로 덮였다 — 곡을 만진 적 없는 「제목만 수정」이 남이 추가한 곡을 - * 전부 지우는 lost update 다. + * 적도 없는 필드가 낡은 로컬 값으로 덮였다. * - * 여기서 고정하는 축은 둘이다. - * 1. 안 건드린 필드는 페이로드에서 `null` 이 된다 → 요청에서 키째 빠진다 → 서버가 「유지」로 읽는다. - * 2. **비운 것은 안 건드린 것과 다르다** — 빈 목록은 그대로 실려 「전부 삭제」로 나간다 - * (#1596·#1599 가 세운 계약). + * 고정하는 축은 셋이다. + * 1. 안 건드린 슬롯은 페이로드에서 빠진다 → 요청에서 키째 빠진다 → 서버가 「유지」로 읽는다. + * 2. **비운 것은 안 건드린 것과 다르다** — 빈 목록·명시적 삭제는 그대로 실린다 (#1596·#1599). + * 3. **판정 단위는 서버가 반영하는 단위와 같다** — 계정 정보는 id·비밀번호를 따로, 플레이리스트는 + * 사진·영상·곡을 따로 잰다. 객체째 재면 안 건드린 형제 슬롯이 낡은 값으로 딸려 나간다. */ class AfternoteEditorPartialUpdateTest { private val socialDetail = @@ -94,14 +95,14 @@ class AfternoteEditorPartialUpdateTest { private fun buildSocialUpdate( payload: RegisterAfternotePayload = untouchedSocialPayload(), selectedReceiverIds: List = listOf(11L, 22L), - baseline: AfternoteUpdatePayload? = AfternoteEditorFormMapper.buildUpdateBaseline(socialDetail), + detail: Detail = socialDetail, ) = AfternoteEditorFormMapper.buildUpdatePayload( type = AfternoteType.SOCIAL_NETWORK, payload = payload, selectedReceiverIds = selectedReceiverIds, playlistSongs = emptyList(), memorialMedia = MemorialMediaUrls(), - baseline = baseline, + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail), ) @Test @@ -142,10 +143,6 @@ class AfternoteEditorPartialUpdateTest { assertEquals(listOf(ReceiverRefPayload(receiverId = 11L)), updated.receivers) } - /** - * 「전부 삭제」와 「안 건드림」이 갈린다 — 빈 목록은 접히지 않고 그대로 실려야 한다. - * 접어 버리면 사용자가 전부 지운 저장이 서버에 반영되지 않는다. - */ @Test fun `수신자를 전부 빼면 빈 목록이 실려 전부 삭제로 나간다`() { val updated = buildSocialUpdate(selectedReceiverIds = emptyList()) @@ -160,6 +157,29 @@ class AfternoteEditorPartialUpdateTest { assertEquals(emptyList(), updated.processingMethods) } + /** + * 서버는 `actions` 원소를 검증 없이 저장하므로 `[""]` 가 실제로 남아 있을 수 있다. 비교 전에 + * 빈 문자열을 걷어내면 그 행을 지운 저장이 「양쪽 다 빈 목록」으로 상쇄돼 **삭제가 사라진다.** + */ + @Test + fun `서버에 빈 문자열 처리 방법만 있을 때 그 행을 지우면 삭제가 나간다`() { + val detailWithBlank = + socialDetail.copy( + content = + DetailContent.SocialNetwork( + credentials = DetailCredentials(id = "account", password = "pw"), + processingMethods = listOf(""), + ), + ) + val updated = + buildSocialUpdate( + payload = untouchedSocialPayload(processingMethods = emptyList()), + detail = detailWithBlank, + ) + + assertEquals(emptyList(), updated.processingMethods) + } + @Test fun `남기실 말씀을 전부 빼면 빈 목록이 실려 전부 삭제로 나간다`() { val updated = buildSocialUpdate(payload = untouchedSocialPayload(messageBlocks = emptyList())) @@ -167,14 +187,27 @@ class AfternoteEditorPartialUpdateTest { assertEquals(emptyList(), updated.leaveMessageBlocks) } + /** + * 서버가 id 와 비밀번호를 **따로** 갱신하므로(`CredentialsRelationSupport.update`), 안 고친 쪽은 + * 실으면 안 된다. 함께 실으면 그 사이 다른 기기가 바꾼 id 가 낡은 값으로 되돌아간다. + */ @Test - fun `계정 정보를 고치면 계정 정보가 실린다`() { + fun `비밀번호만 고치면 아이디는 실리지 않는다`() { val updated = buildSocialUpdate(payload = untouchedSocialPayload(password = "새 비밀번호")) assertEquals("새 비밀번호", updated.credentials?.password) + assertNull("아이디는 만진 적이 없다", updated.credentials?.id) assertNull("제목은 그대로다", updated.title) } + @Test + fun `아이디만 고치면 비밀번호는 실리지 않는다`() { + val updated = buildSocialUpdate(payload = untouchedSocialPayload(accountId = "새 아이디")) + + assertEquals("새 아이디", updated.credentials?.id) + assertNull("비밀번호는 만진 적이 없다", updated.credentials?.password) + } + /** * 에디터가 띄워 두는 빈 입력 칸은 도메인으로 넘어가기 전에 버려진다. 그 결과가 서버 원본과 * 같다면 「고쳤다」가 아니다 — 여기서 흔들리면 저장할 때마다 남기실 말씀이 다시 실린다. @@ -196,18 +229,6 @@ class AfternoteEditorPartialUpdateTest { assertNull(updated.leaveMessageBlocks) } - /** 기준이 없으면(상세 로드 실패 등) 덜 보내다 사용자의 편집을 잃느니 종전처럼 전량을 싣는다. */ - @Test - fun `기준 스냅샷이 없으면 종전처럼 전량을 싣는다`() { - val updated = buildSocialUpdate(baseline = null) - - assertEquals("인스타그램", updated.title) - assertEquals(listOf("계정 삭제", "사진 백업"), updated.processingMethods) - assertNotNull(updated.leaveMessageBlocks) - assertNotNull(updated.credentials) - assertEquals(2, updated.receivers?.size) - } - private fun buildMemorialUpdate( serviceName: String = "추억 노트", playlistSongs: List = @@ -239,13 +260,37 @@ class AfternoteEditorPartialUpdateTest { assertNull("곡을 건드린 적이 없다", updated.memorial) } + /** + * **곡만 고친 저장이 사진·영상을 건드리면 안 된다.** + * + * 사진·영상 슬롯은 기본값이 없는 DTO 로 나가던 동안 `null` 이 곧 삭제였다. 곡을 바꿨다는 이유로 + * 셋을 함께 실으면, 그 사이 다른 기기가 올린 사진·영상이 명시적 null 로 지워진다. + */ + @Test + fun `곡만 고치면 사진과 영상 슬롯은 말하지 않는다`() { + val updated = + buildMemorialUpdate( + playlistSongs = + listOf(Song(selectionKey = "detail:0", title = "새 곡", artist = "가수", albumCoverUrl = null)), + ) + + val memorial = requireNotNull(updated.memorial) { "곡을 고쳤으므로 플레이리스트가 실려야 한다" } + assertEquals( + listOf(MemorialSongPayload(title = "새 곡", artist = "가수", coverUrl = null)), + memorial.songs, + ) + assertEquals("사진을 건드린 적이 없다", FieldPatch.Unchanged, memorial.memorialPhotoUrl) + assertEquals("영상을 건드린 적이 없다", FieldPatch.Unchanged, memorial.memorialVideo) + } + @Test - fun `곡을 전부 빼면 플레이리스트가 빈 곡 목록과 함께 실린다`() { + fun `곡을 전부 빼면 빈 곡 목록만 실리고 미디어는 말하지 않는다`() { val updated = buildMemorialUpdate(playlistSongs = emptyList()) val memorial = requireNotNull(updated.memorial) { "곡을 지웠으므로 플레이리스트가 실려야 한다" } - assertTrue("곡을 지운 것은 명시적 삭제다", memorial.songs.isEmpty()) - assertEquals("같이 실리는 사진은 종전 값 그대로다", "https://cdn.test/afternotes/photo.jpg", memorial.memorialPhotoUrl) + assertTrue("곡을 지운 것은 명시적 삭제다", memorial.songs?.isEmpty() == true) + assertEquals(FieldPatch.Unchanged, memorial.memorialPhotoUrl) + assertEquals(FieldPatch.Unchanged, memorial.memorialVideo) } /** 선택 키는 화면 전용 식별자라 서버로 나가지 않는다 — 재조회로 값이 달라져도 변경이 아니다. */ @@ -260,8 +305,9 @@ class AfternoteEditorPartialUpdateTest { assertNull(updated.memorial) } + /** 사진만 지운 저장은 사진 슬롯만 삭제로 말하고, 곡·영상은 건드리지 않는다. */ @Test - fun `영정 사진을 지우면 플레이리스트가 실려 삭제를 말한다`() { + fun `영정 사진만 지우면 사진 슬롯만 삭제로 나간다`() { val updated = buildMemorialUpdate( memorialMedia = @@ -272,7 +318,35 @@ class AfternoteEditorPartialUpdateTest { ), ) - assertNotNull(updated.memorial) - assertNull("빈 사진은 명시적 null 로 나가 삭제가 된다", updated.memorial?.memorialPhotoUrl) + val memorial = requireNotNull(updated.memorial) { "사진을 지웠으므로 플레이리스트가 실려야 한다" } + assertEquals(FieldPatch.Set(null), memorial.memorialPhotoUrl) + assertNull("곡을 건드린 적이 없다", memorial.songs) + assertEquals("영상을 건드린 적이 없다", FieldPatch.Unchanged, memorial.memorialVideo) + } + + @Test + fun `추모 영상만 바꾸면 영상 슬롯만 실린다`() { + val updated = + buildMemorialUpdate( + memorialMedia = + MemorialMediaUrls( + memorialVideoUrl = "https://cdn.test/afternotes/new-video.mp4", + memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + memorialPhotoUrl = "https://cdn.test/afternotes/photo.jpg", + ), + ) + + val memorial = requireNotNull(updated.memorial) { "영상을 바꿨으므로 플레이리스트가 실려야 한다" } + assertEquals( + FieldPatch.Set( + MemorialVideoPayload( + videoUrl = "https://cdn.test/afternotes/new-video.mp4", + thumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + ), + ), + memorial.memorialVideo, + ) + assertEquals("사진을 건드린 적이 없다", FieldPatch.Unchanged, memorial.memorialPhotoUrl) + assertNull("곡을 건드린 적이 없다", memorial.songs) } } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorProcessDeathPrefillTest.kt new file mode 100644 index 0000000000..94a17aeb98 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -0,0 +1,137 @@ +package com.afternote.feature.afternote.presentation.author.editor + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.testing.FakeUserRepository +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 +import com.afternote.feature.afternote.domain.model.author.DetailTimestamps +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.presentation.author.editor.model.EditorContentPrefill +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * **알려진 한계를 드러내는 가드** — 프로세스 사망 복원이 사용자의 미저장 편집을 서버 값으로 덮는다. + * + * 복원 경로는 두 갈래가 겹친다: `SavedStateHandle` 의 폼 스냅샷이 편집분을 되살리고, 곧이어 + * `init` 의 상세 재조회가 `pendingPrefill` 을 다시 발행해 UI 가 폼을 서버 값으로 덮어쓴다. + * 이 테스트는 그 두 번째 발행이 **폼이 더러운지 묻지 않고** 일어난다는 사실을 고정한다. + * + * 이 축은 #1617 의 부분 PATCH 가 만든 것이 아니고 고치지도 않는다 — 스냅샷 복원과 프리필 재적용이 + * 함께 있던 종전부터의 동작이다. 부분 PATCH 는 오히려 이 경로를 **덜 위험하게** 만든다: 덮인 폼은 + * 서버 값과 같아져 「바뀐 것 없음」으로 아무 슬롯도 실리지 않고, 프리필이 도착하기 전 저장을 + * 시도하면 기준이 없다는 이유로 [AfternoteEditorMissingBaselineTest] 의 가드가 막는다. + * + * 제대로 고치려면 「복원된 편집이 있으면 프리필을 덮어쓰지 않거나 사용자에게 고를 기회를 준다」는 + * 화면 흐름 결정이 필요하다 — 편집 유실 안내·충돌 해소 UI 는 #1617 의 해야 할 일 2번(서버 거절 시 + * 화면 동작)과 같은 결의 별도 축이라 그쪽에서 다룬다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteEditorProcessDeathPrefillTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `복원된 편집이 있어도 상세 재조회 프리필이 다시 발행된다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.success(serverDetail()) } + } + val viewModel = viewModel(repository) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + // 복원 직후의 폼은 사용자가 고친 제목을 들고 있다. + assertEquals(EDITED_SERVICE, viewModel.currentForm().selectedService) + + val prefill = viewModel.uiState.value.pendingPrefill + assertNotNull("상세 재조회는 폼이 더러운지 묻지 않고 프리필을 발행한다", prefill) + assertEquals( + "그 프리필은 서버 값이라, UI 가 적용하면 사용자의 편집이 사라진다", + SERVER_SERVICE, + (prefill?.content as EditorContentPrefill.Gallery).serviceName, + ) + } + + private fun serverDetail() = + Detail( + id = EDIT_ID, + serviceName = SERVER_SERVICE, + timestamps = DetailTimestamps(updatedAt = "2026-08-30"), + receivers = emptyList(), + leaveMessageBlocks = emptyList(), + content = DetailContent.Gallery(processingMethods = listOf("파일 전달")), + ) + + /** 프로세스 사망 전에 저장돼 있던 폼 스냅샷 — 사용자가 제목을 고쳐 둔 상태다. */ + private fun restoredSnapshot(): String = + """ + {"type":"GALLERY_AND_FILES","selectedService":"$EDITED_SERVICE","receivers":[], + "processingMethods":[],"memorialPlaylistSongs":[]} + """.trimIndent().replace("\n", "") + + private fun viewModel(afternoteRepository: FakeAfternoteRepository): AfternoteEditorViewModel = + AfternoteEditorViewModel( + savedStateHandle = + SavedStateHandle( + mapOf( + "initialType" to AfternoteType.GALLERY_AND_FILES, + "itemId" to EDIT_ID, + "editor_form_snapshot_v3" to restoredSnapshot(), + ), + ), + userRepository = FakeUserRepository.strict(), + afternoteRepository = afternoteRepository, + memorialThumbnailUploadRepository = + MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, + resolveMemorialMediaForSave = + ResolveMemorialMediaForSaveUseCase( + MemorialMediaUploadRepository { _, _ -> Result.success(null) }, + ), + errorReporter = NoopErrorReporter, + ) + + private object NoopErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit + } + + private companion object { + const val EDIT_ID = 73L + const val EDITED_SERVICE = "사용자가 고친 제목" + const val SERVER_SERVICE = "구글 포토" + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt index 24fe4ba8cf..f2e9c50af8 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt @@ -3,6 +3,12 @@ package com.afternote.feature.afternote.presentation.author.editor import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput +import com.afternote.feature.afternote.domain.model.author.Detail +import com.afternote.feature.afternote.domain.model.author.DetailContent +import com.afternote.feature.afternote.domain.model.author.DetailCredentials +import com.afternote.feature.afternote.domain.model.author.DetailTimestamps +import com.afternote.feature.afternote.domain.model.author.playlist.MemorialDetail +import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia import com.afternote.feature.afternote.presentation.author.editor.message.EditorMessageTextBlock import com.afternote.feature.afternote.presentation.author.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.author.editor.state.AfternoteTypeForm @@ -94,9 +100,10 @@ class EditorLeaveMessageBlocksTest { selectedReceiverIds = emptyList(), playlistSongs = emptyList(), memorialMedia = MemorialMediaUrls(), - // 여기서 보는 것은 블록을 *어떻게 빚는가* 라서, 변경 판정이 끼어들지 않게 기준을 비운다. - // 안 건드린 필드를 빼는 축은 AfternoteEditorPartialUpdateTest 가 따로 고정한다 (#1617). - baseline = null, + // 여기서 보는 것은 블록을 *어떻게 빚는가* 다. 말씀이 없던 노트를 기준으로 삼아 + // 「달라졌다」가 확실하게 성립하게 두고, 빚어진 모양만 본다 — 안 건드린 필드를 빼는 + // 축은 AfternoteEditorPartialUpdateTest 가 따로 고정한다 (#1617). + baseline = emptyBaseline(AfternoteType.SOCIAL_NETWORK), ) assertEquals( @@ -134,9 +141,9 @@ class EditorLeaveMessageBlocksTest { selectedReceiverIds = emptyList(), playlistSongs = emptyList(), memorialMedia = MemorialMediaUrls(), - // 여기서 보는 것은 블록을 *어떻게 빚는가* 라서, 변경 판정이 끼어들지 않게 기준을 비운다. - // 안 건드린 필드를 빼는 축은 AfternoteEditorPartialUpdateTest 가 따로 고정한다 (#1617). - baseline = null, + // 여기서 보는 것은 블록을 *어떻게 빚는가* 다. 말씀이 없던 노트를 기준으로 삼아 + // 「달라졌다」가 확실하게 성립하게 두고, 빚어진 모양만 본다 (#1617). + baseline = emptyBaseline(AfternoteType.MEMORIAL), ) assertEquals( @@ -176,4 +183,31 @@ class EditorLeaveMessageBlocksTest { date = "2026.08.07", messageBlocks = blocks.toList(), ) + + /** 말씀도 내용도 없던 노트 — 폼에 무엇이 들어 있든 「달라졌다」가 성립하는 기준. */ + private fun emptyBaseline(type: AfternoteType) = + AfternoteEditorFormMapper.buildUpdateBaseline( + Detail( + id = 1L, + serviceName = "", + timestamps = DetailTimestamps(updatedAt = "2026-08-30"), + receivers = emptyList(), + leaveMessageBlocks = emptyList(), + content = + when (type) { + AfternoteType.MEMORIAL -> { + DetailContent.Memorial( + memorial = MemorialDetail(emptyList(), MemorialMedia(null, null, null)), + ) + } + + else -> { + DetailContent.SocialNetwork( + credentials = DetailCredentials(id = "", password = ""), + processingMethods = emptyList(), + ) + } + }, + ), + ) } From a8b59f4e681a54d56380c6ab3400f6f2cd893498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 31 Aug 2026 02:48:55 +0900 Subject: [PATCH 04/15] =?UTF-8?q?docs(afternote):=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=EC=9D=B4=20=EB=90=9C=20MemorialWritePayload?= =?UTF-8?q?=20=EC=9D=98=20KDoc=20=EC=9D=84=20=EB=B0=94=EB=A1=9C=EC=9E=A1?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20(#1617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/model/author/MemorialWritePayload.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/MemorialWritePayload.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/MemorialWritePayload.kt index 204a4f0182..9cfdb9c0ab 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/MemorialWritePayload.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/MemorialWritePayload.kt @@ -10,15 +10,16 @@ data class CreateMemorialPayload( ) /** - * 추억 노트 미디어·곡의 **폼 전체 스냅샷**. 「지금 이 노트가 가져야 할 상태」를 통째로 말한다. + * 추억 노트 미디어·곡의 **폼 전체 스냅샷** — **생성(POST) 전용**이다. + * 「지금 이 노트가 가져야 할 상태」를 통째로 말한다. * - * 수정 저장에서 미디어의 `null` 은 「모르겠다」가 아니라 「비어 있다 — 서버에 있으면 지워라」로 - * 나간다. 그래서 기본값을 두지 않는다: 슬롯마다 뜻을 적게 해, 일부만 아는 호출부가 나머지를 - * 조용히 지우는 일을 타입에서 막는다. 와이어에서 그 뜻이 어떻게 표현되는지는 - * `AfternotePlaylistRequestDto` 의 KDoc 에 있다 (#1596). + * 슬롯에 기본값을 두지 않는 것이 계약이다: 뜻을 적게 해, 일부만 아는 호출부가 나머지를 조용히 + * 비우는 일을 타입에서 막는다. 와이어에서 그 뜻이 어떻게 표현되는지는 + * `AfternotePlaylistRequestDto` 의 KDoc 에 있다 (#1596·#1599). * - * [songs] 도 같은 뜻이다 — 빈 목록은 「모르겠다」가 아니라 「곡이 하나도 없다」로 나가 서버 곡을 - * 전부 지운다. 와이어 표현만 다르다: 미디어는 JSON `null`, 곡은 **빈 배열**이다 (#1599). + * **수정(PATCH)에는 쓰지 않는다.** 수정은 「만진 슬롯만」 말해야 하는데 이 타입은 그것을 표현할 + * 수 없다 — 전체를 말하므로, 곡만 바꾼 저장도 사진·영상을 함께 실어 그 사이 다른 기기가 올린 + * 미디어를 지운다. 수정은 [MemorialPatchPayload] 를 쓴다 (#1617). */ data class MemorialWritePayload( val memorialPhotoUrl: String?, From 891ebd0cebd8982223d28771a65bf8900be06b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 31 Aug 2026 02:56:11 +0900 Subject: [PATCH 05/15] =?UTF-8?q?fix(afternote):=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=ED=94=8C=EB=A0=88=EC=9D=B4=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20DTO=20=EC=9D=B4=EB=A6=84=EC=9D=84=20Reques?= =?UTF-8?q?t=20=EA=B7=9C=EC=95=BD=EC=97=90=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= =?UTF-8?q?=20(#1617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit konsist ResponseDtoContractKonsistTest 는 이름에 Request 가 없는 dto 를 응답으로 보고 non-null 기본값을 금지한다. 이 DTO 는 요청 바디이므로 규약대로 이름을 고쳐 올바르게 분류되게 한다 — 규칙을 예외로 뚫지 않는다. --- .../com/afternote/feature/afternote/data/dto/AfternoteDto.kt | 4 ++-- .../feature/afternote/data/mapper/AfternoteRequestMapper.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 92374ba4fd..57ad52aae7 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 @@ -62,7 +62,7 @@ data class AfternoteUpdateRequestDto( @SerialName("leaveMessage") val leaveMessage: List? = null, @SerialName("credentials") val credentials: AfternoteCredentialsDto? = null, @SerialName("receivers") val receivers: List? = null, - @SerialName("playlist") val memorial: AfternotePlaylistPatchDto? = null, + @SerialName("playlist") val memorial: AfternotePlaylistPatchRequestDto? = null, ) @Serializable @@ -144,7 +144,7 @@ data class AfternotePlaylistRequestDto( * 두지 않아 영영 생략된다. */ @Serializable -data class AfternotePlaylistPatchDto( +data class AfternotePlaylistPatchRequestDto( @SerialName("memorialPhotoUrl") @Serializable(with = FieldPatchSerializer::class) val memorialPhotoUrl: FieldPatch = FieldPatch.Unchanged, diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt index 1b0871c6c3..f68cd5c391 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt @@ -5,7 +5,7 @@ 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.AfternotePlaylistPatchDto +import com.afternote.feature.afternote.data.dto.AfternotePlaylistPatchRequestDto import com.afternote.feature.afternote.data.dto.AfternotePlaylistRequestDto import com.afternote.feature.afternote.data.dto.AfternoteReceiverRefDto import com.afternote.feature.afternote.data.dto.AfternoteSongDto @@ -49,7 +49,7 @@ fun AfternoteUpdatePayload.toRequest() = * 표현된다. */ fun MemorialPatchPayload.toPatchDto() = - AfternotePlaylistPatchDto( + AfternotePlaylistPatchRequestDto( memorialPhotoUrl = memorialPhotoUrl, songs = songs?.map { it.toDto() }, memorialVideo = memorialVideo.map { it?.toDto() }, From b1793b456a7d33057ebf0c80f1884c70120a9555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Wed, 2 Sep 2026 21:32:23 +0900 Subject: [PATCH 06/15] =?UTF-8?q?test(afternote):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=84=B8=EC=8A=A4=20=EC=82=AC=EB=A7=9D=20=EB=B3=B5=EC=9B=90=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9D=98=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=83=B7=20=ED=82=A4=EB=A5=BC=20v4=20=EB=A1=9C=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=EB=8B=A4=20(#1617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop 이 추모 영상을 값 객체로 바꾸며(#1561) 폼 스냅샷 키를 editor_form_snapshot_v4 로 올렸다. 이 테스트는 v3 키로 스냅샷을 심어 복원이 일어나지 않았고, 「복원된 편집이 있어도 프리필이 다시 발행된다」 는 전제 자체가 성립하지 않아 실패했다. 스냅샷 JSON 은 v4 필드(memorialVideo 기본 null)와 호환되므로 키만 맞춘다. --- .../author/editor/AfternoteEditorProcessDeathPrefillTest.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/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorProcessDeathPrefillTest.kt index 94a17aeb98..6b3c88fd2e 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorProcessDeathPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -108,7 +108,7 @@ class AfternoteEditorProcessDeathPrefillTest { mapOf( "initialType" to AfternoteType.GALLERY_AND_FILES, "itemId" to EDIT_ID, - "editor_form_snapshot_v3" to restoredSnapshot(), + "editor_form_snapshot_v4" to restoredSnapshot(), ), ), userRepository = FakeUserRepository.strict(), From edb59533fcd1f56002278646635238f6ac6d9858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sat, 12 Sep 2026 06:43:12 +0900 Subject: [PATCH 07/15] =?UTF-8?q?chore:=20=EC=98=9B=20guard=20check-run=20?= =?UTF-8?q?=EC=9D=84=20=EA=B1=B7=EC=96=B4=EB=82=B4=EB=A0=A4=20HEAD=20?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=B1=EC=8B=A0=ED=95=9C=EB=8B=A4=20(#1983)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SHA 에 붙은 재게시 guard check-run 은 지울 수 없어 새 HEAD 로만 사라진다. From c757588cce66de4b822e5be6f644c1e1ebdc9372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 20 Sep 2026 21:38:10 +0900 Subject: [PATCH 08/15] =?UTF-8?q?test:=20=EB=B3=B5=EC=9B=90=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=ED=95=84=20=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=9D=98=20=ED=98=84=EC=9E=AC=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=ED=82=A4=20=EC=82=AC=EC=9A=A9=20(#1617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/AfternoteEditorProcessDeathPrefillTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt index 0254a4e30d..bf3472d2fb 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -110,7 +110,7 @@ class AfternoteEditorProcessDeathPrefillTest { mapOf( "initialType" to AfternoteType.GALLERY_AND_FILES, "itemId" to EDIT_ID, - "editor_form_snapshot_v4" to restoredSnapshot(), + "editor_form_snapshot_v5" to restoredSnapshot(), ), ), userRepository = FakeUserRepository.strict(), From 9b7e19d7dae1819f101990c9a6eece372ab37d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 25 Sep 2026 14:40:48 +0900 Subject: [PATCH 09/15] =?UTF-8?q?refactor(afternote):=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=EB=AA=A8=EB=8D=B8=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9D=84=20Input=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC=20(#16?= =?UTF-8?q?17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/mapper/AfternoteRequestMapper.kt | 8 ++-- .../author/AfternoteRepositoryImpl.kt | 4 +- .../dto/AfternotePlaylistRequestWireTest.kt | 16 ++++---- .../dto/AfternoteResponseDtoContractTest.kt | 12 +++--- .../AfternoteUpdatePartialPatchWireTest.kt | 38 +++++++++---------- .../data/mapper/AfternoteDraftRequestTest.kt | 4 +- ...t.kt => UpdateAfternoteInputMapperTest.kt} | 8 ++-- .../model/author/MemorialWritePayload.kt | 4 +- .../model/author/SaveAfternoteCommand.kt | 2 +- ...datePayload.kt => UpdateAfternoteInput.kt} | 6 +-- .../repository/author/AfternoteRepository.kt | 4 +- .../testing/FakeAfternoteRepositoryTest.kt | 6 +-- .../domain/testing/FakeAfternoteRepository.kt | 14 +++---- .../editor/AfternoteEditorFormMapper.kt | 12 +++--- 14 files changed, 69 insertions(+), 69 deletions(-) rename feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/{AfternoteUpdatePayloadMapperTest.kt => UpdateAfternoteInputMapperTest.kt} (70%) rename feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/{AfternoteUpdatePayload.kt => UpdateAfternoteInput.kt} (95%) diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt index a46cc0877d..8d2f80a4a1 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt @@ -12,16 +12,16 @@ 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.FieldPatch -import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload +import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput 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 +import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput /** * 도메인 → 요청 wire. 요청 바디 루트는 `toRequest`, 그 안에 실리는 부분 DTO 는 `toDto` 다. @@ -30,7 +30,7 @@ import com.afternote.feature.afternote.domain.model.author.ReceiverRefPayload * `AfternoteEditorFormMapper` 가 프리필 원본과 비교해 정했다 — 여기서 `ifEmpty { null }` 류로 한 번 * 더 손대면 「전부 삭제」가 「안 건드림」으로 조용히 바뀐다. */ -fun AfternoteUpdatePayload.toRequest() = +fun UpdateAfternoteInput.toRequest() = AfternoteUpdateRequestDto( category = type.toAuthoringServerCategory(), title = title, @@ -49,7 +49,7 @@ fun AfternoteUpdatePayload.toRequest() = * 후자는 남의 변경을 되돌리는 덮어쓰기로 나간다. 「말하지 않음」은 끝까지 말하지 않는 것으로만 * 표현된다. */ -fun MemorialPatchPayload.toPatchDto() = +fun MemorialPatchInput.toPatchDto() = AfternotePlaylistPatchRequestDto( memorialPhotoUrl = memorialPhotoUrl, songs = songs?.map { it.toDto() }, 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 530c3b186f..8431921f73 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 @@ -16,13 +16,13 @@ import com.afternote.feature.afternote.data.paging.AfternotePagingSource import com.afternote.feature.afternote.data.service.AfternoteApiService import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.error.AfternoteFailure -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.Detail import com.afternote.feature.afternote.domain.model.author.DraftDetail import com.afternote.feature.afternote.domain.model.author.ListItem +import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -93,7 +93,7 @@ class AfternoteRepositoryImpl override suspend fun update( id: Long, - payload: AfternoteUpdatePayload, + payload: UpdateAfternoteInput, ): Result = runCatchingCancellable { api diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternotePlaylistRequestWireTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternotePlaylistRequestWireTest.kt index c40c00df6f..621f63491c 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternotePlaylistRequestWireTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternotePlaylistRequestWireTest.kt @@ -3,11 +3,11 @@ package com.afternote.feature.afternote.data.dto import com.afternote.core.network.di.NetworkModule import com.afternote.feature.afternote.data.mapper.toRequest import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload import com.afternote.feature.afternote.domain.model.author.FieldPatch -import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload +import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput 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.UpdateAfternoteInput import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonArray @@ -29,20 +29,20 @@ import org.junit.Test class AfternotePlaylistRequestWireTest { private val json = NetworkModule.provideJson() - private fun updateBody(memorial: MemorialPatchPayload?) = + private fun updateBody(memorial: MemorialPatchInput?) = json .encodeToJsonElement( - AfternoteUpdatePayload( + UpdateAfternoteInput( type = if (memorial == null) AfternoteType.SOCIAL_NETWORK else AfternoteType.MEMORIAL, title = "제목", memorial = memorial, ).toRequest(), ).jsonObject - private fun playlistOf(memorial: MemorialPatchPayload) = updateBody(memorial).getValue("playlist").jsonObject + private fun playlistOf(memorial: MemorialPatchInput) = updateBody(memorial).getValue("playlist").jsonObject private val filled = - MemorialPatchPayload( + MemorialPatchInput( memorialPhotoUrl = FieldPatch.Set("https://cdn.test/afternotes/photo.jpg"), songs = listOf(MemorialSongPayload(title = "곡", artist = "가수", coverUrl = null)), memorialVideo = @@ -61,7 +61,7 @@ class AfternotePlaylistRequestWireTest { * [AfternoteUpdatePartialPatchWireTest] 가 JSON 문자열째 고정한다. */ private val emptied = - MemorialPatchPayload( + MemorialPatchInput( memorialPhotoUrl = FieldPatch.Set(null), songs = emptyList(), memorialVideo = FieldPatch.Set(null), @@ -69,7 +69,7 @@ class AfternotePlaylistRequestWireTest { /** 발행된 PLAYLIST PATCH 검증을 통과하도록 기존 곡을 함께 싣는 실제 서버 미디어 삭제 스냅샷. */ private val deletedServerMedia = - MemorialPatchPayload( + MemorialPatchInput( memorialPhotoUrl = FieldPatch.Set(null), songs = listOf(MemorialSongPayload(title = "기존 곡", artist = "기존 가수", coverUrl = null)), memorialVideo = FieldPatch.Set(null), diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteResponseDtoContractTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteResponseDtoContractTest.kt index db8304fd41..a597b6757d 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteResponseDtoContractTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteResponseDtoContractTest.kt @@ -2,11 +2,11 @@ package com.afternote.feature.afternote.data.dto import com.afternote.feature.afternote.data.mapper.toRequest import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload -import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload +import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput import com.afternote.feature.afternote.domain.model.author.MemorialSongPayload import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload +import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.MissingFieldException import kotlinx.serialization.json.Json @@ -114,11 +114,11 @@ class AfternoteResponseDtoContractTest { @Test fun `곡을 전부 뺀 수정 요청은 songs를 빈 배열로 실어 전부 삭제를 말한다`() { val request = - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.MEMORIAL, title = "추억 노트", // 곡을 전부 뺀 것만 말한다 — 미디어 슬롯은 만지지 않았으므로 키가 나가면 안 된다 (#1617). - memorial = MemorialPatchPayload(songs = emptyList()), + memorial = MemorialPatchInput(songs = emptyList()), ).toRequest() val encoded = json.encodeToJsonElement(AfternoteUpdateRequestDto.serializer(), request).jsonObject @@ -133,11 +133,11 @@ class AfternoteResponseDtoContractTest { @Test fun `곡이 있는 수정 요청의 songs는 종전과 같은 키와 값으로 실린다`() { val request = - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.MEMORIAL, title = "추억 노트", memorial = - MemorialPatchPayload( + MemorialPatchInput( songs = listOf(MemorialSongPayload(title = "곡", artist = "가수", coverUrl = null)), ), ).toRequest() diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt index 3dd02ef54a..e3f3c3065d 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt @@ -5,12 +5,12 @@ import com.afternote.feature.afternote.data.mapper.toRequest import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock 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.FieldPatch -import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload +import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput 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.ReceiverRefPayload +import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import org.junit.Assert.assertEquals import org.junit.Test @@ -27,7 +27,7 @@ import org.junit.Test class AfternoteUpdatePartialPatchWireTest { private val json = NetworkModule.provideJson() - private fun wire(payload: AfternoteUpdatePayload): String = json.encodeToString(payload.toRequest()) + private fun wire(payload: UpdateAfternoteInput): String = json.encodeToString(payload.toRequest()) /** * 이슈 #1617 이 든 재현 경로의 반대편 — 제목만 고친 저장은 나머지를 **말하지 않는다.** @@ -35,7 +35,7 @@ class AfternoteUpdatePartialPatchWireTest { */ @Test fun `제목만 실은 수정은 카테고리와 제목 두 키만 나간다`() { - val body = wire(AfternoteUpdatePayload(type = AfternoteType.SOCIAL_NETWORK, title = "새 제목")) + val body = wire(UpdateAfternoteInput(type = AfternoteType.SOCIAL_NETWORK, title = "새 제목")) assertEquals("""{"category":"SOCIAL","title":"새 제목"}""", body) } @@ -43,7 +43,7 @@ class AfternoteUpdatePartialPatchWireTest { /** 제목조차 안 건드렸으면 제목도 빠진다 — 서버가 `title` 생략을 계약으로 못박아 뒀다. */ @Test fun `아무것도 안 실으면 카테고리만 나간다`() { - val body = wire(AfternoteUpdatePayload(type = AfternoteType.MEMORIAL)) + val body = wire(UpdateAfternoteInput(type = AfternoteType.MEMORIAL)) assertEquals("""{"category":"PLAYLIST"}""", body) } @@ -59,10 +59,10 @@ class AfternoteUpdatePartialPatchWireTest { fun `곡만 바꾼 수정은 playlist 안에 songs 키만 남긴다`() { val body = wire( - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.MEMORIAL, memorial = - MemorialPatchPayload( + MemorialPatchInput( songs = listOf(MemorialSongPayload(title = "곡", artist = "가수", coverUrl = null)), ), ), @@ -79,9 +79,9 @@ class AfternoteUpdatePartialPatchWireTest { fun `곡을 전부 빼면 빈 배열만 나가고 미디어 키는 없다`() { val body = wire( - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.MEMORIAL, - memorial = MemorialPatchPayload(songs = emptyList()), + memorial = MemorialPatchInput(songs = emptyList()), ), ) @@ -93,9 +93,9 @@ class AfternoteUpdatePartialPatchWireTest { fun `영정 사진만 지우면 사진 슬롯에만 명시적 null 이 나간다`() { val body = wire( - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.MEMORIAL, - memorial = MemorialPatchPayload(memorialPhotoUrl = FieldPatch.Set(null)), + memorial = MemorialPatchInput(memorialPhotoUrl = FieldPatch.Set(null)), ), ) @@ -106,9 +106,9 @@ class AfternoteUpdatePartialPatchWireTest { fun `추모 영상만 지우면 영상 슬롯에만 명시적 null 이 나간다`() { val body = wire( - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.MEMORIAL, - memorial = MemorialPatchPayload(memorialVideo = FieldPatch.Set(null)), + memorial = MemorialPatchInput(memorialVideo = FieldPatch.Set(null)), ), ) @@ -119,10 +119,10 @@ class AfternoteUpdatePartialPatchWireTest { fun `미디어에 값을 실으면 종전과 같은 모양으로 나간다`() { val body = wire( - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.MEMORIAL, memorial = - MemorialPatchPayload( + MemorialPatchInput( memorialPhotoUrl = FieldPatch.Set("https://cdn.test/afternotes/photo.jpg"), memorialVideo = FieldPatch.Set( @@ -153,7 +153,7 @@ class AfternoteUpdatePartialPatchWireTest { fun `비밀번호만 고치면 credentials 안에 password 키만 나간다`() { val body = wire( - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.SOCIAL_NETWORK, credentials = AfternoteAccountCredentials(password = "새 비밀번호"), ), @@ -166,7 +166,7 @@ class AfternoteUpdatePartialPatchWireTest { fun `아이디만 고치면 credentials 안에 id 키만 나간다`() { val body = wire( - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.SOCIAL_NETWORK, credentials = AfternoteAccountCredentials(id = "새 아이디"), ), @@ -180,7 +180,7 @@ class AfternoteUpdatePartialPatchWireTest { fun `빈 목록은 생략되지 않고 빈 배열로 나간다`() { val body = wire( - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.GALLERY_AND_FILES, processingMethods = emptyList(), leaveMessageBlocks = emptyList(), @@ -198,7 +198,7 @@ class AfternoteUpdatePartialPatchWireTest { fun `값을 실은 필드는 종전과 같은 키로 그대로 나간다`() { val body = wire( - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.BUSINESS, title = "제목", processingMethods = listOf("계정 삭제"), diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteDraftRequestTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteDraftRequestTest.kt index efe71e20e5..78b0c38c80 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteDraftRequestTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteDraftRequestTest.kt @@ -2,11 +2,11 @@ package com.afternote.feature.afternote.data.mapper import com.afternote.core.network.di.NetworkModule import com.afternote.feature.afternote.domain.AfternoteType -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.MemorialWritePayload +import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -107,5 +107,5 @@ class AfternoteDraftRequestTest { ) private fun updatePayload(isDraft: Boolean? = null) = - AfternoteUpdatePayload(type = AfternoteType.SOCIAL_NETWORK, title = "t", isDraft = isDraft) + UpdateAfternoteInput(type = AfternoteType.SOCIAL_NETWORK, title = "t", isDraft = isDraft) } diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteUpdatePayloadMapperTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/UpdateAfternoteInputMapperTest.kt similarity index 70% rename from feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteUpdatePayloadMapperTest.kt rename to feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/UpdateAfternoteInputMapperTest.kt index 67da376edd..c6b431a9c9 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteUpdatePayloadMapperTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/UpdateAfternoteInputMapperTest.kt @@ -1,12 +1,12 @@ package com.afternote.feature.afternote.data.mapper import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload +import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -class AfternoteUpdatePayloadMapperTest { +class UpdateAfternoteInputMapperTest { @Test fun `도메인 종류는 data 경계에서 작성 API category로 변환된다`() { val expectedByType = @@ -18,13 +18,13 @@ class AfternoteUpdatePayloadMapperTest { ) expectedByType.forEach { (type, expected) -> - assertEquals(expected, AfternoteUpdatePayload(type = type, title = "title").toRequest().category) + assertEquals(expected, UpdateAfternoteInput(type = type, title = "title").toRequest().category) } } @Test fun `저장 미지원 종류는 wire 요청으로 만들지 않는다`() { - val result = runCatching { AfternoteUpdatePayload(type = AfternoteType.ESTATE, title = "title").toRequest() } + val result = runCatching { UpdateAfternoteInput(type = AfternoteType.ESTATE, title = "title").toRequest() } assertTrue(result.exceptionOrNull() is IllegalStateException) } diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/MemorialWritePayload.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/MemorialWritePayload.kt index 680532f568..54013219bd 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/MemorialWritePayload.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/MemorialWritePayload.kt @@ -21,7 +21,7 @@ data class CreateMemorialPayload( * * **수정(PATCH)에는 쓰지 않는다.** 수정은 「만진 슬롯만」 말해야 하는데 이 타입은 그것을 표현할 * 수 없다 — 전체를 말하므로, 곡만 바꾼 저장도 사진·영상을 함께 실어 그 사이 다른 기기가 올린 - * 미디어를 지운다. 수정은 [MemorialPatchPayload] 를 쓴다 (#1617). + * 미디어를 지운다. 수정은 [MemorialPatchInput] 를 쓴다 (#1617). */ data class MemorialWritePayload( val memorialPhotoUrl: String?, @@ -46,7 +46,7 @@ data class MemorialVideoPayload( * - [memorialPhotoUrl]·[memorialVideo] 는 **키 유무**로 유지/삭제를 가르므로 [FieldPatch] 가 필요하다. * - [songs] 는 서버가 `songs != null` 만 보므로 `null`(안 건드림)과 빈 배열(전부 삭제)로 충분하다. */ -data class MemorialPatchPayload( +data class MemorialPatchInput( val memorialPhotoUrl: FieldPatch = FieldPatch.Unchanged, val songs: List? = null, val memorialVideo: FieldPatch = FieldPatch.Unchanged, diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/SaveAfternoteCommand.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/SaveAfternoteCommand.kt index 91244bfa55..e363375612 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/SaveAfternoteCommand.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/SaveAfternoteCommand.kt @@ -7,6 +7,6 @@ sealed interface SaveAfternoteCommand { data class Update( val id: Long, - val payload: AfternoteUpdatePayload, + val payload: UpdateAfternoteInput, ) : SaveAfternoteCommand } diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AfternoteUpdatePayload.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/UpdateAfternoteInput.kt similarity index 95% rename from feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AfternoteUpdatePayload.kt rename to feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/UpdateAfternoteInput.kt index af7caea57c..ef83942f53 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AfternoteUpdatePayload.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/UpdateAfternoteInput.kt @@ -31,16 +31,16 @@ import com.afternote.feature.afternote.domain.model.LeaveMessageBlock * **판정 단위는 서버가 반영하는 단위와 같아야 한다.** 객체를 통째로 「바뀌었다/아니다」로 재면, 그 * 객체 안에서 안 건드린 형제 슬롯이 낡은 값째 함께 실려 나간다. 그래서 [credentials] 는 id·비밀번호를 * 따로 재고(서버도 `CredentialsRelationSupport.update` 가 둘을 독립으로 갱신한다), [memorial] 은 - * 슬롯별 [FieldPatch] 를 든 [MemorialPatchPayload] 로 말한다 (#1617). + * 슬롯별 [FieldPatch] 를 든 [MemorialPatchInput] 로 말한다 (#1617). */ -data class AfternoteUpdatePayload( +data class UpdateAfternoteInput( val type: AfternoteType, val title: String? = null, val processingMethods: List? = null, val leaveMessageBlocks: List? = null, val credentials: AfternoteAccountCredentials? = null, val receivers: List? = null, - val memorial: MemorialPatchPayload? = null, + val memorial: MemorialPatchInput? = null, /** * 임시저장 여부를 바꿀 때만 값을 싣는다. null 이면 서버가 저장값을 유지하므로, * 이어쓰기 중 «임시저장으로 계속» 은 null·true 어느 쪽이든 되고 «정식 등록» 만 false 를 명시한다. diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/AfternoteRepository.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/AfternoteRepository.kt index 7bec855941..a129ae786e 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/AfternoteRepository.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/AfternoteRepository.kt @@ -2,13 +2,13 @@ package com.afternote.feature.afternote.domain.repository.author import androidx.paging.PagingData import com.afternote.feature.afternote.domain.AfternoteType -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.Detail import com.afternote.feature.afternote.domain.model.author.DraftDetail import com.afternote.feature.afternote.domain.model.author.ListItem +import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import kotlinx.coroutines.flow.Flow interface AfternoteRepository { @@ -44,7 +44,7 @@ interface AfternoteRepository { suspend fun update( id: Long, - payload: AfternoteUpdatePayload, + payload: UpdateAfternoteInput, ): Result suspend fun delete(id: Long): Result diff --git a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepositoryTest.kt b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepositoryTest.kt index a3ee0b301e..c53ff3da7d 100644 --- a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepositoryTest.kt +++ b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepositoryTest.kt @@ -3,7 +3,6 @@ package com.afternote.feature.afternote.domain.testing import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.Account 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 @@ -15,6 +14,7 @@ import com.afternote.feature.afternote.domain.model.author.DetailTimestamps import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload import com.afternote.feature.afternote.domain.model.author.ReceiverRefPayload +import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.flow.take @@ -38,7 +38,7 @@ class FakeAfternoteRepositoryTest { initialDetails = mapOf(detail.id to detail), ) val payload = - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.SOCIAL_NETWORK, title = "수정 서비스", processingMethods = listOf("계정 보존"), @@ -149,7 +149,7 @@ class FakeAfternoteRepositoryTest { title = "추억", memorial = MemorialWritePayload(memorialPhotoUrl = null, songs = emptyList(), memorialVideo = null), ) - val updatePayload = AfternoteUpdatePayload(type = AfternoteType.ESTATE, title = "유산") + val updatePayload = UpdateAfternoteInput(type = AfternoteType.ESTATE, title = "유산") assertUnexpected { repository.getPagedAfternotes(null) } assertUnexpected { repository.getDetail(1L) } diff --git a/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt b/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt index 4314e77216..f5169a48ba 100644 --- a/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt +++ b/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt @@ -4,7 +4,6 @@ import androidx.paging.PagingData import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.Account 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 @@ -16,6 +15,7 @@ import com.afternote.feature.afternote.domain.model.author.DraftDetail import com.afternote.feature.afternote.domain.model.author.FieldPatch import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.model.author.MemorialVideoPayload +import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import com.afternote.feature.afternote.domain.model.author.playlist.DetailSong import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository @@ -49,7 +49,7 @@ class FakeAfternoteRepository( var onCreateBusiness: (suspend (CreateAccountPayload) -> Result)? = null, var onCreateGallery: (suspend (CreateGalleryPayload) -> Result)? = null, var onCreateMemorial: (suspend (CreateMemorialPayload) -> Result)? = null, - var onUpdate: (suspend (Long, AfternoteUpdatePayload) -> Result)? = null, + var onUpdate: (suspend (Long, UpdateAfternoteInput) -> Result)? = null, var onDelete: (suspend (Long) -> Result)? = null, ) : AfternoteRepository { val items = CopyOnWriteArrayList(initialItems) @@ -62,7 +62,7 @@ class FakeAfternoteRepository( val businessPayloads = CopyOnWriteArrayList() val galleryPayloads = CopyOnWriteArrayList() val memorialPayloads = CopyOnWriteArrayList() - val updateCalls = CopyOnWriteArrayList>() + val updateCalls = CopyOnWriteArrayList>() val deletedIds = CopyOnWriteArrayList() val requestedDraftDetailIds = CopyOnWriteArrayList() @@ -121,7 +121,7 @@ class FakeAfternoteRepository( override suspend fun update( id: Long, - payload: AfternoteUpdatePayload, + payload: UpdateAfternoteInput, ): Result { updateCalls += id to payload onUpdate?.let { return it(id, payload) } @@ -175,7 +175,7 @@ class FakeAfternoteRepository( * 수정은 **부분 갱신**이라 `null` 슬롯은 「안 건드림」이다 — 서버와 같이 기존 값을 남긴다 (#1617). * 여기서 덮어써 버리면 페이로드가 필드를 뺐는지 여부를 테스트가 구분하지 못한다. */ -private fun ListItem.updatedWith(payload: AfternoteUpdatePayload): ListItem = +private fun ListItem.updatedWith(payload: UpdateAfternoteInput): ListItem = copy( serviceName = payload.title ?: serviceName, type = payload.type, @@ -192,7 +192,7 @@ private fun Account.updatedWith(credentials: AfternoteAccountCredentials?): Acco ) } -private fun Detail.updatedWith(payload: AfternoteUpdatePayload): Detail = +private fun Detail.updatedWith(payload: UpdateAfternoteInput): Detail = copy( serviceName = payload.title ?: serviceName, receivers = @@ -204,7 +204,7 @@ private fun Detail.updatedWith(payload: AfternoteUpdatePayload): Detail = content = content.updatedWith(payload), ) -private fun DetailContent.updatedWith(payload: AfternoteUpdatePayload): DetailContent = +private fun DetailContent.updatedWith(payload: UpdateAfternoteInput): DetailContent = when (payload.type) { AfternoteType.SOCIAL_NETWORK -> { val previous = this as? DetailContent.SocialNetwork diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt index c2feb5c404..ccec8b557d 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt @@ -3,7 +3,6 @@ package com.afternote.feature.afternote.presentation.editor import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock 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.CreateAfternoteInput import com.afternote.feature.afternote.domain.model.author.CreateGalleryPayload @@ -12,11 +11,12 @@ import com.afternote.feature.afternote.domain.model.author.Detail import com.afternote.feature.afternote.domain.model.author.DetailContent import com.afternote.feature.afternote.domain.model.author.DetailCredentials import com.afternote.feature.afternote.domain.model.author.FieldPatch -import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload +import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput 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 +import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia import com.afternote.feature.afternote.presentation.editor.AfternoteEditorFormMapper.buildUpdatePayload import com.afternote.feature.afternote.presentation.editor.memorial.Song @@ -244,7 +244,7 @@ internal object AfternoteEditorFormMapper { playlistSongs: List, memorialMedia: MemorialMediaUrls, baseline: AfternoteEditorSnapshot, - ): AfternoteUpdatePayload { + ): UpdateAfternoteInput { if (type == AfternoteType.ESTATE) { // placeholder 카테고리는 Validator 에서 차단됨. 도달 시 호출자 버그. error("Unimplemented type cannot be saved: $type") @@ -257,7 +257,7 @@ internal object AfternoteEditorFormMapper { playlistSongs = playlistSongs, memorialMedia = memorialMedia, ) - return AfternoteUpdatePayload( + return UpdateAfternoteInput( type = type, title = current.title.takeIf { it.trim() != baseline.title.trim() }, // 빈 문자열을 걷어내지 않는다 — 서버는 actions 원소를 검증 없이 저장하므로 `[""]` 가 @@ -288,10 +288,10 @@ internal object AfternoteEditorFormMapper { private fun diffMemorial( current: AfternoteEditorSnapshot, baseline: AfternoteEditorSnapshot, - ): MemorialPatchPayload? { + ): MemorialPatchInput? { if (current.type != AfternoteType.MEMORIAL) return null val patch = - MemorialPatchPayload( + MemorialPatchInput( memorialPhotoUrl = FieldPatch.changedOrUnchanged(current.memorialPhotoUrl, baseline.memorialPhotoUrl), songs = current.songs.takeIf { it != baseline.songs }, From 3aa2856f09fe4d4df31cda8d5b984f1da5ea69b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 25 Sep 2026 15:36:36 +0900 Subject: [PATCH 10/15] =?UTF-8?q?refactor(afternote):=20=EB=A7=88=EC=A7=80?= =?UTF-8?q?=EB=A7=89=20=EB=A7=90=EC=94=80=20DTO=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EB=AA=A9=EC=A0=81=EC=9D=84=20=ED=95=A8=EC=88=98=EB=AA=85?= =?UTF-8?q?=EC=97=90=20=EB=AA=85=EC=8B=9C=20(#1617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../afternote/data/mapper/AfternoteRequestMapper.kt | 10 +++++----- .../afternote/data/mapper/LeaveMessageBlockMapper.kt | 6 +++--- .../data/mapper/LeaveMessageBlockContractTest.kt | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt index 8d2f80a4a1..79a002da65 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt @@ -35,7 +35,7 @@ fun UpdateAfternoteInput.toRequest() = category = type.toAuthoringServerCategory(), title = title, processingMethods = processingMethods, - leaveMessage = leaveMessageBlocks.toPatchDto(), + leaveMessage = leaveMessageBlocks.toDtoForPatch(), credentials = credentials?.toDto(), receivers = receivers?.map { it.toDto() }, memorial = memorial?.toPatchDto(), @@ -78,7 +78,7 @@ fun CreateAccountPayload.toSocialRequest() = category = "SOCIAL", title = title, processingMethods = processingMethods, - leaveMessage = leaveMessageBlocks.toDto(), + leaveMessage = leaveMessageBlocks.toDtoForCreate(), credentials = credentials?.toDto(), receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) }, isDraft = isDraft, @@ -89,7 +89,7 @@ fun CreateAccountPayload.toBusinessRequest() = category = "BUSINESS", title = title, processingMethods = processingMethods, - leaveMessage = leaveMessageBlocks.toDto(), + leaveMessage = leaveMessageBlocks.toDtoForCreate(), credentials = credentials?.toDto(), receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) }, isDraft = isDraft, @@ -100,7 +100,7 @@ fun CreateGalleryPayload.toRequest() = category = "GALLERY", title = title, processingMethods = processingMethods, - leaveMessage = leaveMessageBlocks.toDto(), + leaveMessage = leaveMessageBlocks.toDtoForCreate(), receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) }, isDraft = isDraft, ) @@ -110,7 +110,7 @@ fun CreateMemorialPayload.toRequest() = category = "PLAYLIST", title = title, memorial = memorial.toDto(), - leaveMessage = leaveMessageBlocks.toDto(), + leaveMessage = leaveMessageBlocks.toDtoForCreate(), receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) }, isDraft = isDraft, ) diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockMapper.kt index 71c6977050..ac69854cf3 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockMapper.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/LeaveMessageBlockMapper.kt @@ -15,15 +15,15 @@ fun List?.toLeaveMessageBlocks(): List }.orEmpty() /** 도메인 → 생성 요청. 남길 말씀이 없으면 빈 배열 대신 필드 자체를 빼도록 null 로 접는다. */ -fun List.toDto(): List? = +internal fun List.toDtoForCreate(): List? = map { LeaveMessageBlockDto(title = it.title, body = it.body) } .takeIf { it.isNotEmpty() } /** - * 도메인 → 수정 요청. **[toDto] 처럼 빈 배열을 null 로 접지 않는다** (#1617). + * 도메인 → 수정 요청. **[toDtoForCreate] 처럼 빈 배열을 null 로 접지 않는다** (#1617). * * 수정은 부분 갱신이라 `null`(안 건드림 → 키 생략)과 `[]`(전부 삭제)가 서로 다른 지시다. * 접어 버리면 마지막 말씀을 전부 지운 저장이 「유지」로 흡수돼 서버에 반영되지 않는다. */ -fun List?.toPatchDto(): List? = +internal fun List?.toDtoForPatch(): List? = this?.map { LeaveMessageBlockDto(title = it.title, body = it.body) } 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 e8ad8b7791..d88b8e8409 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 @@ -139,7 +139,7 @@ class LeaveMessageBlockContractTest { title = "인스타그램", processingMethods = listOf("게시물 내리기"), leaveMessage = - listOf(LeaveMessageBlock(title = "가족에게", body = "잘 부탁해")).toDto(), + listOf(LeaveMessageBlock(title = "가족에게", body = "잘 부탁해")).toDtoForCreate(), receivers = emptyList(), ) @@ -152,7 +152,7 @@ class LeaveMessageBlockContractTest { @Test fun `요청 직렬화 - 블록이 없으면 필드를 싣지 않는다`() { - assertNull(emptyList().toDto()) + assertNull(emptyList().toDtoForCreate()) } /** PLAYLIST 생성 요청만 `leaveMessage` 가 빠져 입력이 조용히 버려지던 회귀 가드 (이슈 #678). */ From 58bdf0f254cc8e9a2cf5fe2ac9fabbc80a84c6f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 25 Sep 2026 16:00:19 +0900 Subject: [PATCH 11/15] =?UTF-8?q?refactor(afternote):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EC=B0=B8=EC=A1=B0=20=EC=9E=85=EB=A0=A5=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=9D=B4=EB=A6=84=20=EC=A0=95=EB=A6=AC=20(#1617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/afternote/data/mapper/AfternoteRequestMapper.kt | 4 ++-- .../data/dto/AfternoteUpdatePartialPatchWireTest.kt | 4 ++-- .../afternote/domain/model/author/UpdateAfternoteInput.kt | 4 ++-- .../afternote/domain/testing/FakeAfternoteRepositoryTest.kt | 4 ++-- .../presentation/editor/AfternoteEditorFormMapper.kt | 4 ++-- .../afternote/presentation/AfternoteAuthorExtendedTest.kt | 2 +- .../presentation/editor/AfternoteEditorPartialUpdateTest.kt | 6 +++--- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt index 79a002da65..9568015068 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt @@ -20,7 +20,7 @@ import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput 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 +import com.afternote.feature.afternote.domain.model.author.ReceiverRefInput import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput /** @@ -141,7 +141,7 @@ fun AfternoteAccountCredentials.toDto() = password = password, ) -fun ReceiverRefPayload.toDto() = +fun ReceiverRefInput.toDto() = AfternoteReceiverRefDto( receiverId = receiverId, ) diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt index e3f3c3065d..602ecf09fd 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt @@ -9,7 +9,7 @@ import com.afternote.feature.afternote.domain.model.author.FieldPatch import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput 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.ReceiverRefPayload +import com.afternote.feature.afternote.domain.model.author.ReceiverRefInput import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import org.junit.Assert.assertEquals import org.junit.Test @@ -204,7 +204,7 @@ class AfternoteUpdatePartialPatchWireTest { processingMethods = listOf("계정 삭제"), leaveMessageBlocks = listOf(LeaveMessageBlock(title = "가족에게", body = "고마웠어")), credentials = AfternoteAccountCredentials(id = "account", password = "pw"), - receivers = listOf(ReceiverRefPayload(receiverId = 11L)), + receivers = listOf(ReceiverRefInput(receiverId = 11L)), ), ) diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/UpdateAfternoteInput.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/UpdateAfternoteInput.kt index ef83942f53..ab4d6a2f9b 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/UpdateAfternoteInput.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/UpdateAfternoteInput.kt @@ -39,7 +39,7 @@ data class UpdateAfternoteInput( val processingMethods: List? = null, val leaveMessageBlocks: List? = null, val credentials: AfternoteAccountCredentials? = null, - val receivers: List? = null, + val receivers: List? = null, val memorial: MemorialPatchInput? = null, /** * 임시저장 여부를 바꿀 때만 값을 싣는다. null 이면 서버가 저장값을 유지하므로, @@ -60,6 +60,6 @@ data class AfternoteAccountCredentials( val password: String? = null, ) -data class ReceiverRefPayload( +data class ReceiverRefInput( val receiverId: Long, ) diff --git a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepositoryTest.kt b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepositoryTest.kt index c53ff3da7d..33cecc7433 100644 --- a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepositoryTest.kt +++ b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepositoryTest.kt @@ -13,7 +13,7 @@ import com.afternote.feature.afternote.domain.model.author.DetailReceiver import com.afternote.feature.afternote.domain.model.author.DetailTimestamps import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload -import com.afternote.feature.afternote.domain.model.author.ReceiverRefPayload +import com.afternote.feature.afternote.domain.model.author.ReceiverRefInput import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async @@ -43,7 +43,7 @@ class FakeAfternoteRepositoryTest { title = "수정 서비스", processingMethods = listOf("계정 보존"), credentials = AfternoteAccountCredentials(id = "new@test.local"), - receivers = listOf(ReceiverRefPayload(7L)), + receivers = listOf(ReceiverRefInput(7L)), ) val result = repository.update(item.id, payload) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt index ccec8b557d..d068efa43a 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt @@ -15,7 +15,7 @@ import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput 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 +import com.afternote.feature.afternote.domain.model.author.ReceiverRefInput import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia import com.afternote.feature.afternote.presentation.editor.AfternoteEditorFormMapper.buildUpdatePayload @@ -269,7 +269,7 @@ internal object AfternoteEditorFormMapper { receivers = current.receiverIds ?.takeIf { it.sorted() != baseline.receiverIds?.sorted() } - ?.map { ReceiverRefPayload(receiverId = it) }, + ?.map { ReceiverRefInput(receiverId = it) }, memorial = diffMemorial(current, baseline), ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt index 1843359eeb..86631591e4 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt @@ -38,7 +38,7 @@ import com.afternote.feature.afternote.domain.model.author.DetailCredentials import com.afternote.feature.afternote.domain.model.author.DetailReceiver import com.afternote.feature.afternote.domain.model.author.DetailTimestamps import com.afternote.feature.afternote.domain.model.author.ProcessingMethod -import com.afternote.feature.afternote.domain.model.author.ReceiverRefPayload +import com.afternote.feature.afternote.domain.model.author.ReceiverRefInput import com.afternote.feature.afternote.domain.repository.author.MediaInput import com.afternote.feature.afternote.domain.repository.author.MediaKind import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt index 25d35225a1..c062f3162e 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt @@ -10,7 +10,7 @@ import com.afternote.feature.afternote.domain.model.author.DetailTimestamps import com.afternote.feature.afternote.domain.model.author.FieldPatch 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.ReceiverRefPayload +import com.afternote.feature.afternote.domain.model.author.ReceiverRefInput import com.afternote.feature.afternote.domain.model.author.playlist.DetailSong import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia import com.afternote.feature.afternote.presentation.editor.memorial.Song @@ -136,14 +136,14 @@ class AfternoteEditorPartialUpdateTest { fun `수신자를 실제로 지우면 남은 목록이 실린다`() { val updated = buildSocialUpdate(selectedReceiverIds = listOf(11L)) - assertEquals(listOf(ReceiverRefPayload(receiverId = 11L)), updated.receivers) + assertEquals(listOf(ReceiverRefInput(receiverId = 11L)), updated.receivers) } @Test fun `수신자를 전부 빼면 빈 목록이 실려 전부 삭제로 나간다`() { val updated = buildSocialUpdate(selectedReceiverIds = emptyList()) - assertEquals(emptyList(), updated.receivers) + assertEquals(emptyList(), updated.receivers) } @Test From 78f7f907482244cbb42de893ae808795ed0f4d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 25 Sep 2026 16:34:18 +0900 Subject: [PATCH 12/15] =?UTF-8?q?fix(afternote):=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=98=A4=EB=A5=98=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EB=AA=85=ED=99=95=ED=99=94=20(#1617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../afternote/presentation/editor/AfternoteEditorViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt index 2ee0bf10cc..5c95fe5ba5 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt @@ -608,7 +608,7 @@ class AfternoteEditorViewModel // saveAfternote 가 기준 없는 수정을 이미 막았다 — 여기 도달하면 반드시 있다. baseline = checkNotNull(updateBaseline) { - "수정 저장에 기준 스냅샷이 없다 — saveAfternote 의 가드가 빠졌다" + "수정 저장에는 기준 스냅샷이 필요합니다" }, ) SaveAfternoteCommand.Update(id = editingId, payload = updatePayload) From 6ae75396ee86ebab983451755711d9ea359ae595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 25 Sep 2026 19:06:15 +0900 Subject: [PATCH 13/15] =?UTF-8?q?refactor(afternote):=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=EC=9D=98=20=EC=88=98=EC=8B=A0=EC=9E=90?= =?UTF-8?q?=EB=A5=BC=20id=20=EB=AA=A9=EB=A1=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=9B=EA=B3=A0=20wire=20=ED=8F=AC=EC=9E=A5=EC=9D=80=20data?= =?UTF-8?q?=20=EC=97=90=EC=84=9C=20=EB=81=9D=EB=82=B8=EB=8B=A4=20(#1617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/mapper/AfternoteRequestMapper.kt | 20 ++++++++--------- .../AfternoteUpdatePartialPatchWireTest.kt | 22 ++++++++++++++++--- .../model/author/UpdateAfternoteInput.kt | 11 +++++----- .../testing/FakeAfternoteRepositoryTest.kt | 3 +-- .../domain/testing/FakeAfternoteRepository.kt | 6 ++--- .../editor/AfternoteEditorFormMapper.kt | 6 +---- .../AfternoteAuthorExtendedTest.kt | 3 +-- .../AfternoteEditorPartialUpdateTest.kt | 11 +++++----- 8 files changed, 46 insertions(+), 36 deletions(-) diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt index 9568015068..a39e541b8c 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt @@ -20,7 +20,6 @@ import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput 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.ReceiverRefInput import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput /** @@ -37,7 +36,7 @@ fun UpdateAfternoteInput.toRequest() = processingMethods = processingMethods, leaveMessage = leaveMessageBlocks.toDtoForPatch(), credentials = credentials?.toDto(), - receivers = receivers?.map { it.toDto() }, + receivers = receiverIds?.toReceiverRefDtos(), memorial = memorial?.toPatchDto(), isDraft = isDraft, ) @@ -80,7 +79,7 @@ fun CreateAccountPayload.toSocialRequest() = processingMethods = processingMethods, leaveMessage = leaveMessageBlocks.toDtoForCreate(), credentials = credentials?.toDto(), - receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) }, + receivers = receiverIds.toReceiverRefDtos(), isDraft = isDraft, ) @@ -91,7 +90,7 @@ fun CreateAccountPayload.toBusinessRequest() = processingMethods = processingMethods, leaveMessage = leaveMessageBlocks.toDtoForCreate(), credentials = credentials?.toDto(), - receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) }, + receivers = receiverIds.toReceiverRefDtos(), isDraft = isDraft, ) @@ -101,7 +100,7 @@ fun CreateGalleryPayload.toRequest() = title = title, processingMethods = processingMethods, leaveMessage = leaveMessageBlocks.toDtoForCreate(), - receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) }, + receivers = receiverIds.toReceiverRefDtos(), isDraft = isDraft, ) @@ -111,7 +110,7 @@ fun CreateMemorialPayload.toRequest() = title = title, memorial = memorial.toDto(), leaveMessage = leaveMessageBlocks.toDtoForCreate(), - receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) }, + receivers = receiverIds.toReceiverRefDtos(), isDraft = isDraft, ) @@ -141,7 +140,8 @@ fun AfternoteAccountCredentials.toDto() = password = password, ) -fun ReceiverRefInput.toDto() = - AfternoteReceiverRefDto( - receiverId = receiverId, - ) +/** + * 도메인은 수신자를 id 로만 말한다 — wire 의 `{receiverId}` 객체로 감싸는 것은 여기서 끝낸다. + * 순서·중복은 받은 그대로 둔다. + */ +private fun List.toReceiverRefDtos() = map { AfternoteReceiverRefDto(receiverId = it) } diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt index 602ecf09fd..db46c0d0f5 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt @@ -9,7 +9,6 @@ import com.afternote.feature.afternote.domain.model.author.FieldPatch import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput 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.ReceiverRefInput import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import org.junit.Assert.assertEquals import org.junit.Test @@ -184,7 +183,7 @@ class AfternoteUpdatePartialPatchWireTest { type = AfternoteType.GALLERY_AND_FILES, processingMethods = emptyList(), leaveMessageBlocks = emptyList(), - receivers = emptyList(), + receiverIds = emptyList(), ), ) @@ -194,6 +193,23 @@ class AfternoteUpdatePartialPatchWireTest { ) } + /** 수신자 id 는 `{receiverId}` 로 감싸기만 한다 — 폼이 정한 순서와 중복을 data 경계가 고치지 않는다. */ + @Test + fun `수신자 id 목록은 순서와 중복을 그대로 둔 채 receivers 배열로 나간다`() { + val body = + wire( + UpdateAfternoteInput( + type = AfternoteType.SOCIAL_NETWORK, + receiverIds = listOf(22L, 11L, 22L), + ), + ) + + assertEquals( + """{"category":"SOCIAL","receivers":[{"receiverId":22},{"receiverId":11},{"receiverId":22}]}""", + body, + ) + } + @Test fun `값을 실은 필드는 종전과 같은 키로 그대로 나간다`() { val body = @@ -204,7 +220,7 @@ class AfternoteUpdatePartialPatchWireTest { processingMethods = listOf("계정 삭제"), leaveMessageBlocks = listOf(LeaveMessageBlock(title = "가족에게", body = "고마웠어")), credentials = AfternoteAccountCredentials(id = "account", password = "pw"), - receivers = listOf(ReceiverRefInput(receiverId = 11L)), + receiverIds = listOf(11L), ), ) diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/UpdateAfternoteInput.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/UpdateAfternoteInput.kt index ab4d6a2f9b..3fcc496f80 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/UpdateAfternoteInput.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/UpdateAfternoteInput.kt @@ -39,7 +39,12 @@ data class UpdateAfternoteInput( val processingMethods: List? = null, val leaveMessageBlocks: List? = null, val credentials: AfternoteAccountCredentials? = null, - val receivers: List? = null, + /** + * 수신자 id 목록. 위 표의 세 상태를 그대로 따른다 — `null` 은 안 건드림, 빈 목록은 수신자 전부 + * 해제, 값은 이 목록으로 통째 교체다. 원소 순서·중복은 받은 그대로 옮기며, wire 의 + * `receivers: [{receiverId}]` 로 감싸는 것은 data 경계의 몫이다 (#1617). + */ + val receiverIds: List? = null, val memorial: MemorialPatchInput? = null, /** * 임시저장 여부를 바꿀 때만 값을 싣는다. null 이면 서버가 저장값을 유지하므로, @@ -59,7 +64,3 @@ data class AfternoteAccountCredentials( val id: String? = null, val password: String? = null, ) - -data class ReceiverRefInput( - val receiverId: Long, -) diff --git a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepositoryTest.kt b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepositoryTest.kt index 33cecc7433..c9f2b715bb 100644 --- a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepositoryTest.kt +++ b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepositoryTest.kt @@ -13,7 +13,6 @@ import com.afternote.feature.afternote.domain.model.author.DetailReceiver import com.afternote.feature.afternote.domain.model.author.DetailTimestamps import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload -import com.afternote.feature.afternote.domain.model.author.ReceiverRefInput import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async @@ -43,7 +42,7 @@ class FakeAfternoteRepositoryTest { title = "수정 서비스", processingMethods = listOf("계정 보존"), credentials = AfternoteAccountCredentials(id = "new@test.local"), - receivers = listOf(ReceiverRefInput(7L)), + receiverIds = listOf(7L), ) val result = repository.update(item.id, payload) diff --git a/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt b/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt index f5169a48ba..95bb80a8df 100644 --- a/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt +++ b/feature/afternote/domain/src/testFixtures/kotlin/com/afternote/feature/afternote/domain/testing/FakeAfternoteRepository.kt @@ -196,9 +196,9 @@ private fun Detail.updatedWith(payload: UpdateAfternoteInput): Detail = copy( serviceName = payload.title ?: serviceName, receivers = - payload.receivers?.map { ref -> - receivers.firstOrNull { it.receiverId == ref.receiverId } - ?: DetailReceiver(receiverId = ref.receiverId, name = "", relation = "") + payload.receiverIds?.map { receiverId -> + receivers.firstOrNull { it.receiverId == receiverId } + ?: DetailReceiver(receiverId = receiverId, name = "", relation = "") } ?: receivers, leaveMessageBlocks = payload.leaveMessageBlocks ?: leaveMessageBlocks, content = content.updatedWith(payload), diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt index d068efa43a..810ecc9b62 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt @@ -15,7 +15,6 @@ import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput 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.ReceiverRefInput import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia import com.afternote.feature.afternote.presentation.editor.AfternoteEditorFormMapper.buildUpdatePayload @@ -266,10 +265,7 @@ internal object AfternoteEditorFormMapper { leaveMessageBlocks = current.leaveMessageBlocks.takeIf { it != baseline.leaveMessageBlocks }, credentials = diffCredentials(current, baseline), // 순서는 뜻을 갖지 않으므로 정렬해 견주고, 실을 때는 폼 순서 그대로 보낸다. - receivers = - current.receiverIds - ?.takeIf { it.sorted() != baseline.receiverIds?.sorted() } - ?.map { ReceiverRefInput(receiverId = it) }, + receiverIds = current.receiverIds?.takeIf { it.sorted() != baseline.receiverIds?.sorted() }, memorial = diffMemorial(current, baseline), ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt index 86631591e4..f9be259976 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt @@ -38,7 +38,6 @@ import com.afternote.feature.afternote.domain.model.author.DetailCredentials import com.afternote.feature.afternote.domain.model.author.DetailReceiver import com.afternote.feature.afternote.domain.model.author.DetailTimestamps import com.afternote.feature.afternote.domain.model.author.ProcessingMethod -import com.afternote.feature.afternote.domain.model.author.ReceiverRefInput import com.afternote.feature.afternote.domain.repository.author.MediaInput import com.afternote.feature.afternote.domain.repository.author.MediaKind import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository @@ -155,7 +154,7 @@ class AfternoteAuthorExtendedTest { // 안 만진 나머지 — 키째 빠져야 서버가 기존 값을 유지한다. assertNull("제목을 고친 적이 없다", payload.title) assertNull("남기실 말씀을 고친 적이 없다", payload.leaveMessageBlocks) - assertNull("수신자를 고친 적이 없다", payload.receivers) + assertNull("수신자를 고친 적이 없다", payload.receiverIds) assertNull(payload.memorial) assertEquals(listOf(73L, 73L), repository.requestedDetailIds) assertEquals("73", routedItemId) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt index c062f3162e..0cb5893241 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt @@ -10,7 +10,6 @@ import com.afternote.feature.afternote.domain.model.author.DetailTimestamps import com.afternote.feature.afternote.domain.model.author.FieldPatch 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.ReceiverRefInput import com.afternote.feature.afternote.domain.model.author.playlist.DetailSong import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia import com.afternote.feature.afternote.presentation.editor.memorial.Song @@ -109,7 +108,7 @@ class AfternoteEditorPartialUpdateTest { assertNull("처리 방법을 만진 적이 없다", updated.processingMethods) assertNull("남기실 말씀을 만진 적이 없다", updated.leaveMessageBlocks) assertNull("계정 정보를 만진 적이 없다", updated.credentials) - assertNull("수신자를 만진 적이 없다", updated.receivers) + assertNull("수신자를 만진 적이 없다", updated.receiverIds) assertNull("추억 노트가 아니므로 애초에 없다", updated.memorial) } @@ -121,7 +120,7 @@ class AfternoteEditorPartialUpdateTest { assertNull(updated.processingMethods) assertNull(updated.leaveMessageBlocks) assertNull(updated.credentials) - assertNull(updated.receivers) + assertNull(updated.receiverIds) } /** 순서가 뜻을 갖지 않는 수신자는 정렬이 달라졌다고 「고쳤다」로 읽지 않는다. */ @@ -129,21 +128,21 @@ class AfternoteEditorPartialUpdateTest { fun `수신자 순서만 바뀐 것은 변경이 아니다`() { val updated = buildSocialUpdate(selectedReceiverIds = listOf(22L, 11L)) - assertNull(updated.receivers) + assertNull(updated.receiverIds) } @Test fun `수신자를 실제로 지우면 남은 목록이 실린다`() { val updated = buildSocialUpdate(selectedReceiverIds = listOf(11L)) - assertEquals(listOf(ReceiverRefInput(receiverId = 11L)), updated.receivers) + assertEquals(listOf(11L), updated.receiverIds) } @Test fun `수신자를 전부 빼면 빈 목록이 실려 전부 삭제로 나간다`() { val updated = buildSocialUpdate(selectedReceiverIds = emptyList()) - assertEquals(emptyList(), updated.receivers) + assertEquals(emptyList(), updated.receiverIds) } @Test From 11d645914b00aa2e377cd586b1af345682289d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 25 Sep 2026 19:06:15 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix(afternote):=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=A7=A4=ED=8D=BC=EC=9D=98=20=ED=8C=8C=EC=9D=BC=20=EC=95=88=20?= =?UTF-8?q?toDto=20=ED=97=AC=ED=8D=BC=EB=A5=BC=20private=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A4=84=EC=9D=B8=EB=8B=A4=20(#1617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/mapper/AfternoteRequestMapper.kt | 6 ++-- .../data/mapper/PlaylistInputToDtoTest.kt | 32 +++++++++++-------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt index a39e541b8c..5f4c4edcc5 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteRequestMapper.kt @@ -114,7 +114,7 @@ fun CreateMemorialPayload.toRequest() = isDraft = isDraft, ) -fun MemorialWritePayload.toDto() = +private fun MemorialWritePayload.toDto() = AfternotePlaylistRequestDto( memorialPhotoUrl = memorialPhotoUrl, songs = songs.map { it.toDto() }, @@ -128,13 +128,13 @@ private fun MemorialSongPayload.toDto() = coverUrl = coverUrl, ) -fun MemorialVideoPayload.toDto() = +private fun MemorialVideoPayload.toDto() = AfternoteMemorialVideoDto( videoUrl = videoUrl, thumbnailUrl = thumbnailUrl, ) -fun AfternoteAccountCredentials.toDto() = +private fun AfternoteAccountCredentials.toDto() = AfternoteCredentialsDto( id = id, password = password, 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 0d26b1e4b9..1532e91cde 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 @@ -1,30 +1,36 @@ package com.afternote.feature.afternote.data.mapper +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.MemorialWritePayload import org.junit.Assert.assertEquals import org.junit.Test /** - * 곡 wire 변환 계약. 판정은 요청 바디에 실제로 실리는 단위인 - * [MemorialWritePayload.toDto] 의 `songs` 로 한다 — 곡 하나짜리 매퍼는 그 안의 구현 세부다. + * 곡 wire 변환 계약. 판정은 요청 바디 루트인 [CreateMemorialPayload.toRequest] 의 `memorial.songs` 로 + * 한다 — 추모 노트·곡 하나짜리 매퍼는 그 안의 구현 세부라 파일 밖으로 내지 않는다. */ class PlaylistInputToDtoTest { @Test fun `곡 입력은 서버 계약의 제목 아티스트 커버만 wire로 변환한다`() { val dtoSong = - MemorialWritePayload( - memorialPhotoUrl = null, - songs = - listOf( - MemorialSongPayload( - title = "노래", - artist = "가수", - coverUrl = "cover", - ), + CreateMemorialPayload( + title = "추억 노트", + memorial = + MemorialWritePayload( + memorialPhotoUrl = null, + songs = + listOf( + MemorialSongPayload( + title = "노래", + artist = "가수", + coverUrl = "cover", + ), + ), + memorialVideo = null, ), - memorialVideo = null, - ).toDto() + ).toRequest() + .memorial .songs .single() From bd12e5917d722ba9bf547f0f771cc72728515755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 25 Sep 2026 19:28:43 +0900 Subject: [PATCH 15/15] =?UTF-8?q?refactor(afternote):=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=EC=9D=98=20=EB=AF=B8?= =?UTF-8?q?=EB=94=94=EC=96=B4=20URL=20=EC=9D=B8=EC=9E=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=84=20=ED=83=80=EC=9E=85=EA=B3=BC=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=EB=8B=A4=20(#1617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/editor/AfternoteEditorFormMapper.kt | 10 +++++----- .../presentation/editor/AfternoteEditorViewModel.kt | 2 +- .../editor/AfternoteEditorPartialUpdateTest.kt | 10 +++++----- .../editor/EditorLeaveMessageBlocksTest.kt | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt index 810ecc9b62..fa987d060a 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt @@ -241,7 +241,7 @@ internal object AfternoteEditorFormMapper { payload: RegisterAfternotePayload, selectedReceiverIds: List, playlistSongs: List, - memorialMedia: MemorialMediaUrls, + memorialMediaUrls: MemorialMediaUrls, baseline: AfternoteEditorSnapshot, ): UpdateAfternoteInput { if (type == AfternoteType.ESTATE) { @@ -254,7 +254,7 @@ internal object AfternoteEditorFormMapper { payload = payload, selectedReceiverIds = selectedReceiverIds, playlistSongs = playlistSongs, - memorialMedia = memorialMedia, + memorialMediaUrls = memorialMediaUrls, ) return UpdateAfternoteInput( type = type, @@ -346,7 +346,7 @@ internal object AfternoteEditorFormMapper { payload: RegisterAfternotePayload, selectedReceiverIds: List, playlistSongs: List, - memorialMedia: MemorialMediaUrls, + memorialMediaUrls: MemorialMediaUrls, ): AfternoteEditorSnapshot { val isMemorial = type == AfternoteType.MEMORIAL val hasCredentials = type == AfternoteType.SOCIAL_NETWORK || type == AfternoteType.BUSINESS @@ -358,8 +358,8 @@ internal object AfternoteEditorFormMapper { credentialsId = if (hasCredentials) payload.accountId.ifBlank { null } else null, credentialsPassword = if (hasCredentials) payload.password.ifBlank { null } else null, receiverIds = if (isMemorial) null else selectedReceiverIds, - memorialPhotoUrl = if (isMemorial) memorialMedia.memorialPhotoUrl?.ifBlank { null } else null, - memorialVideo = if (isMemorial) memorialMedia.toVideoPayload() else null, + memorialPhotoUrl = if (isMemorial) memorialMediaUrls.memorialPhotoUrl?.ifBlank { null } else null, + memorialVideo = if (isMemorial) memorialMediaUrls.toVideoPayload() else null, songs = if (isMemorial) { playlistSongs.map { song -> diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt index 5c95fe5ba5..f9c1bf228e 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt @@ -599,7 +599,7 @@ class AfternoteEditorViewModel payload = payload, selectedReceiverIds = selectedReceiverIds, playlistSongs = playlistSongs, - memorialMedia = + memorialMediaUrls = MemorialMediaUrls( memorialVideoUrl = resolved.resolvedVideoUrl, memorialThumbnailUrl = memorialMedia.memorialVideo.displayed?.thumbnailUrl, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt index 0cb5893241..8854b9b1ae 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt @@ -96,7 +96,7 @@ class AfternoteEditorPartialUpdateTest { payload = payload, selectedReceiverIds = selectedReceiverIds, playlistSongs = emptyList(), - memorialMedia = MemorialMediaUrls(), + memorialMediaUrls = MemorialMediaUrls(), baseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail), ) @@ -228,7 +228,7 @@ class AfternoteEditorPartialUpdateTest { serviceName: String = "추억 노트", playlistSongs: List = listOf(Song(selectionKey = "detail:0", title = "곡", artist = "가수", albumCoverUrl = null)), - memorialMedia: MemorialMediaUrls = + memorialMediaUrls: MemorialMediaUrls = MemorialMediaUrls( memorialVideoUrl = "https://cdn.test/afternotes/video.mp4", memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", @@ -239,7 +239,7 @@ class AfternoteEditorPartialUpdateTest { payload = RegisterAfternotePayload(serviceName = serviceName, date = "2026-08-30"), selectedReceiverIds = emptyList(), playlistSongs = playlistSongs, - memorialMedia = memorialMedia, + memorialMediaUrls = memorialMediaUrls, baseline = AfternoteEditorFormMapper.buildUpdateBaseline(memorialDetail), ) @@ -305,7 +305,7 @@ class AfternoteEditorPartialUpdateTest { fun `영정 사진만 지우면 사진 슬롯만 삭제로 나간다`() { val updated = buildMemorialUpdate( - memorialMedia = + memorialMediaUrls = MemorialMediaUrls( memorialVideoUrl = "https://cdn.test/afternotes/video.mp4", memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", @@ -323,7 +323,7 @@ class AfternoteEditorPartialUpdateTest { fun `추모 영상만 바꾸면 영상 슬롯만 실린다`() { val updated = buildMemorialUpdate( - memorialMedia = + memorialMediaUrls = MemorialMediaUrls( memorialVideoUrl = "https://cdn.test/afternotes/new-video.mp4", memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorLeaveMessageBlocksTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorLeaveMessageBlocksTest.kt index f6d99732b7..c2f737e706 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorLeaveMessageBlocksTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorLeaveMessageBlocksTest.kt @@ -98,7 +98,7 @@ class EditorLeaveMessageBlocksTest { ), selectedReceiverIds = emptyList(), playlistSongs = emptyList(), - memorialMedia = MemorialMediaUrls(), + memorialMediaUrls = MemorialMediaUrls(), // 여기서 보는 것은 블록을 *어떻게 빚는가* 다. 말씀이 없던 노트를 기준으로 삼아 // 「달라졌다」가 확실하게 성립하게 두고, 빚어진 모양만 본다 — 안 건드린 필드를 빼는 // 축은 AfternoteEditorPartialUpdateTest 가 따로 고정한다 (#1617). @@ -139,7 +139,7 @@ class EditorLeaveMessageBlocksTest { payload = payloadOf(EditorMessageTextBlock(title = "가족에게", body = "잘 지내")), selectedReceiverIds = emptyList(), playlistSongs = emptyList(), - memorialMedia = MemorialMediaUrls(), + memorialMediaUrls = MemorialMediaUrls(), // 여기서 보는 것은 블록을 *어떻게 빚는가* 다. 말씀이 없던 노트를 기준으로 삼아 // 「달라졌다」가 확실하게 성립하게 두고, 빚어진 모양만 본다 (#1617). baseline = emptyBaseline(AfternoteType.MEMORIAL),