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 fa987d060..476327463 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 @@ -244,10 +244,6 @@ internal object AfternoteEditorFormMapper { memorialMediaUrls: MemorialMediaUrls, baseline: AfternoteEditorSnapshot, ): UpdateAfternoteInput { - if (type == AfternoteType.ESTATE) { - // placeholder 카테고리는 Validator 에서 차단됨. 도달 시 호출자 버그. - error("Unimplemented type cannot be saved: $type") - } val current = buildEditorSnapshot( type = type, @@ -256,43 +252,89 @@ internal object AfternoteEditorFormMapper { playlistSongs = playlistSongs, memorialMediaUrls = memorialMediaUrls, ) - return UpdateAfternoteInput( - 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), - // 순서는 뜻을 갖지 않으므로 정렬해 견주고, 실을 때는 폼 순서 그대로 보낸다. - receiverIds = current.receiverIds?.takeIf { it.sorted() != baseline.receiverIds?.sorted() }, - memorial = diffMemorial(current, baseline), - ) + val common = + UpdateAfternoteInput( + type = type, + title = current.title.takeIf { it.trim() != baseline.title.trim() }, + leaveMessageBlocks = current.leaveMessageBlocks.takeIf { it != baseline.leaveMessageBlocks }, + ) + // 카테고리마다 그 카테고리가 가진 슬롯만 견준다. 없는 슬롯은 기본값(null)이라 요청에서 빠진다. + return when (current) { + is SocialNetworkSnapshot -> { + val origin = baseline.requireSameCategory() + common.copy( + processingMethods = changedProcessingMethods(current.processingMethods, origin.processingMethods), + credentials = changedCredentials(current.credentials, origin.credentials), + receiverIds = changedReceivers(current.receiverIds, origin.receiverIds), + ) + } + + is BusinessSnapshot -> { + val origin = baseline.requireSameCategory() + common.copy( + processingMethods = changedProcessingMethods(current.processingMethods, origin.processingMethods), + credentials = changedCredentials(current.credentials, origin.credentials), + receiverIds = changedReceivers(current.receiverIds, origin.receiverIds), + ) + } + + is GallerySnapshot -> { + val origin = baseline.requireSameCategory() + common.copy( + processingMethods = changedProcessingMethods(current.processingMethods, origin.processingMethods), + receiverIds = changedReceivers(current.receiverIds, origin.receiverIds), + ) + } + + // 추억 노트 수정 페이로드는 수신자를 싣지 않는다. + is MemorialSnapshot -> { + common.copy(memorial = changedMemorial(current, baseline.requireSameCategory())) + } + } } + /** + * 저장 카테고리와 기준 카테고리는 같아야 한다. 수정 화면은 카테고리를 바꿀 수 없고, 호출부는 기준을 + * 만든 상세의 카테고리로 저장한다. 어긋났다면 슬롯을 견줄 수 없으므로 호출자 버그다. + */ + private inline fun AfternoteEditorSnapshot.requireSameCategory(): T = + checkNotNull(this as? T) { "수정 기준 스냅샷의 카테고리가 저장 카테고리와 다릅니다" } + + /** + * 빈 문자열을 걷어내지 않는다. 서버는 actions 원소를 검증 없이 저장하므로 `[""]` 가 실제로 남아 + * 있을 수 있고, 그 행을 지운 저장이 양쪽 정규화로 상쇄되면 삭제가 사라진다. + */ + private fun changedProcessingMethods( + current: List, + baseline: List, + ): List? = current.takeIf { it != baseline } + + /** 순서는 뜻을 갖지 않으므로 정렬해 견주고, 실을 때는 폼 순서 그대로 보낸다. */ + private fun changedReceivers( + current: List, + baseline: List, + ): List? = current.takeIf { it.sorted() != baseline.sorted() } + /** 계정 정보는 서버가 id·비밀번호를 독립으로 갱신하므로 슬롯별로 재고, 둘 다 그대로면 통째로 뺀다. */ - private fun diffCredentials( - current: AfternoteEditorSnapshot, - baseline: AfternoteEditorSnapshot, + private fun changedCredentials( + current: CredentialsSnapshot, + baseline: CredentialsSnapshot, ): AfternoteAccountCredentials? { - val id = current.credentialsId.takeIf { it != baseline.credentialsId } - val password = current.credentialsPassword.takeIf { it != baseline.credentialsPassword } + val id = current.id.takeIf { it != baseline.id } + val password = current.password.takeIf { it != baseline.password } return if (id == null && password == null) null else AfternoteAccountCredentials(id = id, password = password) } /** 플레이리스트는 슬롯 셋을 따로 재고, 하나도 안 바뀌었으면 `playlist` 키 자체를 내보내지 않는다. */ - private fun diffMemorial( - current: AfternoteEditorSnapshot, - baseline: AfternoteEditorSnapshot, + private fun changedMemorial( + current: MemorialSnapshot, + baseline: MemorialSnapshot, ): MemorialPatchInput? { - if (current.type != AfternoteType.MEMORIAL) return null val patch = MemorialPatchInput( - memorialPhotoUrl = - FieldPatch.changedOrUnchanged(current.memorialPhotoUrl, baseline.memorialPhotoUrl), + memorialPhotoUrl = FieldPatch.changedOrUnchanged(current.photoUrl, baseline.photoUrl), songs = current.songs.takeIf { it != baseline.songs }, - memorialVideo = - FieldPatch.changedOrUnchanged(current.memorialVideo, baseline.memorialVideo), + memorialVideo = FieldPatch.changedOrUnchanged(current.video, baseline.video), ) return patch.takeUnless { it.isUnchanged } } @@ -305,39 +347,57 @@ internal object AfternoteEditorFormMapper { * 화면 표시용으로 값을 한 번 가공하기 때문이다 — 비교 기준은 가공 전 원본이어야 한다. */ fun buildUpdateBaseline(detail: Detail): AfternoteEditorSnapshot { - val content = detail.content - val memorial = content as? DetailContent.Memorial - return AfternoteEditorSnapshot( - 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.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 { it.receiverId } - } - }, - 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) - }, - ) + val title = detail.serviceName + val leaveMessageBlocks = detail.leaveMessageBlocks.normalizedForDiff() + val receiverIds = detail.receivers.map { it.receiverId } + return when (val content = detail.content) { + is DetailContent.SocialNetwork -> { + SocialNetworkSnapshot( + title = title, + leaveMessageBlocks = leaveMessageBlocks, + credentials = content.credentials.toCredentialsSnapshot(), + processingMethods = content.processingMethods, + receiverIds = receiverIds, + ) + } + + is DetailContent.Business -> { + BusinessSnapshot( + title = title, + leaveMessageBlocks = leaveMessageBlocks, + credentials = content.credentials.toCredentialsSnapshot(), + processingMethods = content.processingMethods, + receiverIds = receiverIds, + ) + } + + is DetailContent.Gallery -> { + GallerySnapshot( + title = title, + leaveMessageBlocks = leaveMessageBlocks, + processingMethods = content.processingMethods, + receiverIds = receiverIds, + ) + } + + is DetailContent.Memorial -> { + MemorialSnapshot( + title = title, + leaveMessageBlocks = leaveMessageBlocks, + photoUrl = content.media.photoUrl?.ifBlank { null }, + video = content.media.toVideoPayload(), + songs = + content.songs.map { song -> + MemorialSongPayload(title = song.title, artist = song.artist, coverUrl = song.coverUrl) + }, + ) + } + + // 저장은 막히지만 수정 진입은 되므로 기준은 만든다. + DetailContent.Estate -> { + EstateSnapshot(title = title, leaveMessageBlocks = leaveMessageBlocks) + } + } } /** 현재 폼을 기준 스냅샷과 **같은 어휘**로 옮긴다 — 그래야 슬롯끼리 견줄 수 있다. */ @@ -347,36 +407,64 @@ internal object AfternoteEditorFormMapper { selectedReceiverIds: List, playlistSongs: List, memorialMediaUrls: MemorialMediaUrls, - ): AfternoteEditorSnapshot { - val isMemorial = type == AfternoteType.MEMORIAL - val hasCredentials = type == AfternoteType.SOCIAL_NETWORK || type == AfternoteType.BUSINESS - return AfternoteEditorSnapshot( - type = type, - title = payload.serviceName, - 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) memorialMediaUrls.memorialPhotoUrl?.ifBlank { null } else null, - memorialVideo = if (isMemorial) memorialMediaUrls.toVideoPayload() else null, - songs = - if (isMemorial) { - playlistSongs.map { song -> - MemorialSongPayload(title = song.title, artist = song.artist, coverUrl = song.albumCoverUrl) - } - } else { - null - }, - ) - } + ): SavableEditorSnapshot { + val title = payload.serviceName + val leaveMessageBlocks = payload.messageBlocks.toLeaveMessageBlocks().normalizedForDiff() + return when (type) { + AfternoteType.SOCIAL_NETWORK -> { + SocialNetworkSnapshot( + title = title, + leaveMessageBlocks = leaveMessageBlocks, + credentials = payload.toCredentialsSnapshot(), + processingMethods = payload.processingMethods, + receiverIds = selectedReceiverIds, + ) + } - private fun DetailContent.detailCredentials(): DetailCredentials? = - when (this) { - is DetailContent.SocialNetwork -> credentials - is DetailContent.Business -> credentials - is DetailContent.Gallery, is DetailContent.Memorial, DetailContent.Estate -> null + AfternoteType.BUSINESS -> { + BusinessSnapshot( + title = title, + leaveMessageBlocks = leaveMessageBlocks, + credentials = payload.toCredentialsSnapshot(), + processingMethods = payload.processingMethods, + receiverIds = selectedReceiverIds, + ) + } + + AfternoteType.GALLERY_AND_FILES -> { + GallerySnapshot( + title = title, + leaveMessageBlocks = leaveMessageBlocks, + processingMethods = payload.processingMethods, + receiverIds = selectedReceiverIds, + ) + } + + AfternoteType.MEMORIAL -> { + MemorialSnapshot( + title = title, + leaveMessageBlocks = leaveMessageBlocks, + photoUrl = memorialMediaUrls.memorialPhotoUrl?.ifBlank { null }, + video = memorialMediaUrls.toVideoPayload(), + songs = + playlistSongs.map { song -> + MemorialSongPayload(title = song.title, artist = song.artist, coverUrl = song.albumCoverUrl) + }, + ) + } + + // placeholder 카테고리는 Validator 에서 차단됨. 도달 시 호출자 버그. + AfternoteType.ESTATE -> { + error("Unimplemented type cannot be saved: $type") + } } + } + + private fun DetailCredentials.toCredentialsSnapshot() = + CredentialsSnapshot(id = id.ifBlank { null }, password = password.ifBlank { null }) + + private fun RegisterAfternotePayload.toCredentialsSnapshot() = + CredentialsSnapshot(id = accountId.ifBlank { null }, password = password.ifBlank { null }) private fun MemorialMedia.toVideoPayload(): MemorialVideoPayload? = videoUrl?.ifBlank { null }?.let { url -> @@ -393,20 +481,57 @@ internal object AfternoteEditorFormMapper { * 현재 폼과 서버 원본을 견주기 위한 **같은 어휘의 스냅샷** (#1617). * * 「무엇을 보낼까」가 아니라 「무엇이 달라졌나」만 판정하는 자료다. 그래서 wire 표현(키 생략·명시적 - * null·빈 배열)을 담지 않고, 슬롯마다 의미값 하나씩만 든다. 카테고리에 없는 슬롯은 `null` 로 비워 - * 양쪽이 「둘 다 없음」으로 맞아떨어지게 한다. + * null·빈 배열)을 담지 않고, 슬롯마다 의미값 하나씩만 든다. 카테고리마다 그 카테고리가 가진 슬롯만 + * 들며, 분류는 [DetailContent] 와 같다. */ -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?, +internal sealed interface AfternoteEditorSnapshot { + val title: String + val leaveMessageBlocks: List +} + +/** 수정 저장까지 가는 카테고리. [EstateSnapshot] 은 기준으로만 존재한다. */ +private sealed interface SavableEditorSnapshot : AfternoteEditorSnapshot + +private data class SocialNetworkSnapshot( + override val title: String, + override val leaveMessageBlocks: List, + val credentials: CredentialsSnapshot, + val processingMethods: List, + val receiverIds: List, +) : SavableEditorSnapshot + +private data class BusinessSnapshot( + override val title: String, + override val leaveMessageBlocks: List, + val credentials: CredentialsSnapshot, + val processingMethods: List, + val receiverIds: List, +) : SavableEditorSnapshot + +private data class GallerySnapshot( + override val title: String, + override val leaveMessageBlocks: List, + val processingMethods: List, + val receiverIds: List, +) : SavableEditorSnapshot + +private data class MemorialSnapshot( + override val title: String, + override val leaveMessageBlocks: List, + val photoUrl: String?, + val video: MemorialVideoPayload?, + val songs: List, +) : SavableEditorSnapshot + +private data class EstateSnapshot( + override val title: String, + override val leaveMessageBlocks: List, +) : AfternoteEditorSnapshot + +/** 빈 칸은 `null` 로 맞춰 둔다. 서버의 빈 값과 폼의 빈 입력이 같은 값으로 견줘지게. */ +private data class CredentialsSnapshot( + val id: String?, + val password: String?, ) /** 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 8854b9b1a..e7ec98a6c 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 @@ -2,6 +2,7 @@ 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.Detail import com.afternote.feature.afternote.domain.model.author.DetailContent import com.afternote.feature.afternote.domain.model.author.DetailCredentials @@ -10,6 +11,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.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.presentation.editor.memorial.Song @@ -344,4 +346,156 @@ class AfternoteEditorPartialUpdateTest { assertEquals("사진을 건드린 적이 없다", FieldPatch.Unchanged, memorial.memorialPhotoUrl) assertNull("곡을 건드린 적이 없다", memorial.songs) } + + /** 추억 노트는 처리 방법·수신자·계정 정보를 갖지 않는다. 폼에 값이 남아 있어도 싣지 않는다. */ + @Test + fun `추억 노트는 폼에 남은 처리 방법과 수신자와 계정 값을 싣지 않는다`() { + val updated = + AfternoteEditorFormMapper.buildUpdatePayload( + type = AfternoteType.MEMORIAL, + payload = untouchedSocialPayload(serviceName = "추억 노트", messageBlocks = emptyList()), + selectedReceiverIds = listOf(11L), + playlistSongs = + listOf(Song(selectionKey = "detail:0", title = "곡", artist = "가수", albumCoverUrl = null)), + memorialMediaUrls = + MemorialMediaUrls( + memorialVideoUrl = "https://cdn.test/afternotes/video.mp4", + memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + memorialPhotoUrl = "https://cdn.test/afternotes/photo.jpg", + ), + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(memorialDetail), + ) + + assertEquals(UpdateAfternoteInput(type = AfternoteType.MEMORIAL), updated) + } + + @Test + fun `추억 노트에서 남기실 말씀만 고치면 플레이리스트를 말하지 않는다`() { + val updated = + AfternoteEditorFormMapper.buildUpdatePayload( + type = AfternoteType.MEMORIAL, + payload = + RegisterAfternotePayload( + serviceName = "추억 노트", + date = "2026-08-30", + messageBlocks = listOf(EditorMessageTextBlock(title = "", body = "새 말씀", isRegistered = false)), + ), + selectedReceiverIds = emptyList(), + playlistSongs = + listOf(Song(selectionKey = "detail:0", title = "곡", artist = "가수", albumCoverUrl = null)), + memorialMediaUrls = + MemorialMediaUrls( + memorialVideoUrl = "https://cdn.test/afternotes/video.mp4", + memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + memorialPhotoUrl = "https://cdn.test/afternotes/photo.jpg", + ), + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(memorialDetail), + ) + + assertEquals( + UpdateAfternoteInput( + type = AfternoteType.MEMORIAL, + leaveMessageBlocks = listOf(LeaveMessageBlock(title = null, body = "새 말씀")), + ), + updated, + ) + } + + private val businessDetail = + socialDetail.copy( + serviceName = "회사 메일", + content = + DetailContent.Business( + credentials = DetailCredentials(id = "work", password = "pw"), + processingMethods = listOf("계정 삭제"), + ), + ) + + private fun buildBusinessUpdate( + accountId: String = "work", + password: String = "pw", + selectedReceiverIds: List = listOf(11L, 22L), + ) = AfternoteEditorFormMapper.buildUpdatePayload( + type = AfternoteType.BUSINESS, + payload = + untouchedSocialPayload( + serviceName = "회사 메일", + accountId = accountId, + password = password, + processingMethods = listOf("계정 삭제"), + ), + selectedReceiverIds = selectedReceiverIds, + playlistSongs = emptyList(), + memorialMediaUrls = MemorialMediaUrls(), + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(businessDetail), + ) + + @Test + fun `비즈니스도 아무것도 안 고치면 어느 필드도 실리지 않는다`() { + assertEquals(UpdateAfternoteInput(type = AfternoteType.BUSINESS), buildBusinessUpdate()) + } + + @Test + fun `비즈니스에서 비밀번호와 수신자만 고치면 그 둘만 실린다`() { + val updated = buildBusinessUpdate(password = "새 비밀번호", selectedReceiverIds = listOf(22L)) + + assertEquals( + UpdateAfternoteInput( + type = AfternoteType.BUSINESS, + credentials = AfternoteAccountCredentials(password = "새 비밀번호"), + receiverIds = listOf(22L), + ), + updated, + ) + } + + private val galleryDetail = + socialDetail.copy( + serviceName = "사진첩", + content = DetailContent.Gallery(processingMethods = listOf("사진 백업")), + ) + + private fun buildGalleryUpdate( + accountId: String = "", + processingMethods: List = listOf("사진 백업"), + selectedReceiverIds: List = listOf(11L, 22L), + ) = AfternoteEditorFormMapper.buildUpdatePayload( + type = AfternoteType.GALLERY_AND_FILES, + payload = + untouchedSocialPayload( + serviceName = "사진첩", + accountId = accountId, + password = "", + processingMethods = processingMethods, + ), + selectedReceiverIds = selectedReceiverIds, + playlistSongs = emptyList(), + memorialMediaUrls = MemorialMediaUrls(), + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(galleryDetail), + ) + + @Test + fun `갤러리도 아무것도 안 고치면 어느 필드도 실리지 않는다`() { + assertEquals(UpdateAfternoteInput(type = AfternoteType.GALLERY_AND_FILES), buildGalleryUpdate()) + } + + /** 갤러리는 계정 정보를 갖지 않는다. 폼에 아이디가 남아 있어도 계정 정보로 싣지 않는다. */ + @Test + fun `갤러리는 폼에 남은 아이디를 계정 정보로 싣지 않는다`() { + assertNull(buildGalleryUpdate(accountId = "남은 아이디").credentials) + } + + @Test + fun `갤러리에서 처리 방법과 수신자를 비우면 빈 목록이 실린다`() { + val updated = buildGalleryUpdate(processingMethods = emptyList(), selectedReceiverIds = emptyList()) + + assertEquals( + UpdateAfternoteInput( + type = AfternoteType.GALLERY_AND_FILES, + processingMethods = emptyList(), + receiverIds = emptyList(), + ), + updated, + ) + } }