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 9ca8216167..15783f82ce 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 @@ -39,15 +40,32 @@ data class AfternoteCreateAccountRequestDto( @SerialName("isDraft") val isDraft: Boolean = false, ) +/** + * 애프터노트 수정(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, @SerialName("receivers") val receivers: List? = null, - @SerialName("playlist") val memorial: AfternotePlaylistRequestDto? = null, + @SerialName("playlist") val memorial: AfternotePlaylistPatchRequestDto? = null, /** * 임시저장 여부. **생략하면 서버가 저장값을 그대로 유지한다**(BE `AfternoteValidator.validatePublishRequirements`) — * 그래서 «건드리지 않음» 이 null 이고, 발행 전환은 `false` 를 명시해야 일어난다. @@ -113,6 +131,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 AfternotePlaylistPatchRequestDto( + @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 64983dba4d..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 @@ -5,37 +5,63 @@ 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.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 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.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` 다. + * + * 수정 페이로드는 **슬롯을 하나도 접지 않고 그대로 옮긴다** (#1617). 어떤 필드를 실을지는 이미 + * `AfternoteEditorFormMapper` 가 프리필 원본과 비교해 정했다 — 여기서 `ifEmpty { null }` 류로 한 번 + * 더 손대면 「전부 삭제」가 「안 건드림」으로 조용히 바뀐다. */ - -fun AfternoteUpdatePayload.toRequest() = +fun UpdateAfternoteInput.toRequest() = AfternoteUpdateRequestDto( category = type.toAuthoringServerCategory(), title = title, processingMethods = processingMethods, - leaveMessage = leaveMessageBlocks.toDto(), + leaveMessage = leaveMessageBlocks.toDtoForPatch(), credentials = credentials?.toDto(), - receivers = receivers?.map { it.toDto() }, - memorial = memorial?.toDto(), + receivers = receiverIds?.toReceiverRefDtos(), + memorial = memorial?.toPatchDto(), isDraft = isDraft, ) +/** + * 플레이리스트 수정 슬롯 → wire. 슬롯의 [FieldPatch] 를 **그대로** 옮긴다 (#1617). + * + * [FieldPatch.Unchanged] 를 여기서 `null` 이나 기존 값으로 바꿔치기하면 안 된다 — 전자는 삭제로, + * 후자는 남의 변경을 되돌리는 덮어쓰기로 나간다. 「말하지 않음」은 끝까지 말하지 않는 것으로만 + * 표현된다. + */ +fun MemorialPatchInput.toPatchDto() = + AfternotePlaylistPatchRequestDto( + 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) { @@ -51,9 +77,9 @@ fun CreateAccountPayload.toSocialRequest() = category = "SOCIAL", title = title, processingMethods = processingMethods, - leaveMessage = leaveMessageBlocks.toDto(), + leaveMessage = leaveMessageBlocks.toDtoForCreate(), credentials = credentials?.toDto(), - receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) }, + receivers = receiverIds.toReceiverRefDtos(), isDraft = isDraft, ) @@ -62,9 +88,9 @@ fun CreateAccountPayload.toBusinessRequest() = category = "BUSINESS", title = title, processingMethods = processingMethods, - leaveMessage = leaveMessageBlocks.toDto(), + leaveMessage = leaveMessageBlocks.toDtoForCreate(), credentials = credentials?.toDto(), - receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) }, + receivers = receiverIds.toReceiverRefDtos(), isDraft = isDraft, ) @@ -73,8 +99,8 @@ fun CreateGalleryPayload.toRequest() = category = "GALLERY", title = title, processingMethods = processingMethods, - leaveMessage = leaveMessageBlocks.toDto(), - receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) }, + leaveMessage = leaveMessageBlocks.toDtoForCreate(), + receivers = receiverIds.toReceiverRefDtos(), isDraft = isDraft, ) @@ -83,12 +109,12 @@ fun CreateMemorialPayload.toRequest() = category = "PLAYLIST", title = title, memorial = memorial.toDto(), - leaveMessage = leaveMessageBlocks.toDto(), - receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) }, + leaveMessage = leaveMessageBlocks.toDtoForCreate(), + receivers = receiverIds.toReceiverRefDtos(), isDraft = isDraft, ) -fun MemorialWritePayload.toDto() = +private fun MemorialWritePayload.toDto() = AfternotePlaylistRequestDto( memorialPhotoUrl = memorialPhotoUrl, songs = songs.map { it.toDto() }, @@ -102,19 +128,20 @@ 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, ) -fun ReceiverRefPayload.toDto() = - AfternoteReceiverRefDto( - receiverId = receiverId, - ) +/** + * 도메인은 수신자를 id 로만 말한다 — wire 의 `{receiverId}` 객체로 감싸는 것은 여기서 끝낸다. + * 순서·중복은 받은 그대로 둔다. + */ +private fun List.toReceiverRefDtos() = map { AfternoteReceiverRefDto(receiverId = it) } 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..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 @@ -14,7 +14,16 @@ fun List?.toLeaveMessageBlocks(): List LeaveMessageBlock(title = dto.title, body = body) }.orEmpty() -/** 도메인 → 요청. 남길 말씀이 없으면 빈 배열 대신 필드 자체를 빼도록 null 로 접는다. */ -fun List.toDto(): List? = +/** 도메인 → 생성 요청. 남길 말씀이 없으면 빈 배열 대신 필드 자체를 빼도록 null 로 접는다. */ +internal fun List.toDtoForCreate(): List? = map { LeaveMessageBlockDto(title = it.title, body = it.body) } .takeIf { it.isNotEmpty() } + +/** + * 도메인 → 수정 요청. **[toDtoForCreate] 처럼 빈 배열을 null 로 접지 않는다** (#1617). + * + * 수정은 부분 갱신이라 `null`(안 건드림 → 키 생략)과 `[]`(전부 삭제)가 서로 다른 지시다. + * 접어 버리면 마지막 말씀을 전부 지운 저장이 「유지」로 흡수돼 서버에 반영되지 않는다. + */ +internal fun List?.toDtoForPatch(): List? = + this?.map { LeaveMessageBlockDto(title = it.title, body = it.body) } 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 4d15db56ef..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,10 +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.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.UpdateAfternoteInput import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonArray @@ -28,37 +29,50 @@ import org.junit.Test class AfternotePlaylistRequestWireTest { private val json = NetworkModule.provideJson() - private fun updateBody(memorial: MemorialWritePayload?) = + 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: MemorialWritePayload) = updateBody(memorial).getValue("playlist").jsonObject + private fun playlistOf(memorial: MemorialPatchInput) = updateBody(memorial).getValue("playlist").jsonObject private val filled = - MemorialWritePayload( - memorialPhotoUrl = "https://cdn.test/afternotes/photo.jpg", + MemorialPatchInput( + 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 = + MemorialPatchInput( + memorialPhotoUrl = FieldPatch.Set(null), + songs = emptyList(), + memorialVideo = FieldPatch.Set(null), + ) /** 발행된 PLAYLIST PATCH 검증을 통과하도록 기존 곡을 함께 싣는 실제 서버 미디어 삭제 스냅샷. */ private val deletedServerMedia = - MemorialWritePayload( - memorialPhotoUrl = null, + MemorialPatchInput( + memorialPhotoUrl = FieldPatch.Set(null), songs = listOf(MemorialSongPayload(title = "기존 곡", artist = "기존 가수", coverUrl = null)), - memorialVideo = null, + memorialVideo = FieldPatch.Set(null), ) @Test 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..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,10 +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.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 @@ -88,6 +89,7 @@ class AfternoteResponseDtoContractTest { val request = CreateMemorialPayload( title = "추억 노트", + // 생성은 「이 노트가 가져야 할 상태」를 통째로 말하므로 전체 스냅샷 타입 그대로다. memorial = MemorialWritePayload(memorialPhotoUrl = null, songs = emptyList(), memorialVideo = null), ).toRequest() @@ -112,10 +114,11 @@ class AfternoteResponseDtoContractTest { @Test fun `곡을 전부 뺀 수정 요청은 songs를 빈 배열로 실어 전부 삭제를 말한다`() { val request = - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.MEMORIAL, title = "추억 노트", - memorial = MemorialWritePayload(memorialPhotoUrl = null, songs = emptyList(), memorialVideo = null), + // 곡을 전부 뺀 것만 말한다 — 미디어 슬롯은 만지지 않았으므로 키가 나가면 안 된다 (#1617). + memorial = MemorialPatchInput(songs = emptyList()), ).toRequest() val encoded = json.encodeToJsonElement(AfternoteUpdateRequestDto.serializer(), request).jsonObject @@ -130,14 +133,12 @@ class AfternoteResponseDtoContractTest { @Test fun `곡이 있는 수정 요청의 songs는 종전과 같은 키와 값으로 실린다`() { val request = - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.MEMORIAL, title = "추억 노트", memorial = - MemorialWritePayload( - memorialPhotoUrl = null, + MemorialPatchInput( 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 new file mode 100644 index 0000000000..db46c0d0f5 --- /dev/null +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/AfternoteUpdatePartialPatchWireTest.kt @@ -0,0 +1,234 @@ +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.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.UpdateAfternoteInput +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 수정 요청 바디를 **JSON 문자열째** 고정한다 (#1617). + * + * DTO 객체만 단언하면 이 축의 결함이 통째로 빠져나간다 — 슬롯이 「키를 뺐는지」와 「null 을 실었는지」는 + * 직렬화 설정과 기본값의 함수라서, 객체 수준에서는 둘이 똑같아 보인다. 실제로 그렇게 새어 나갔다: + * 기본값 없는 `memorialPhotoUrl` 이 `null` 이면 키를 달고 나가 삭제 지시가 됐다. + * + * 그래서 앱이 실제로 쓰는 [NetworkModule.provideJson] 으로 인코딩한 **문자열 전체**를 견준다. + * 누가 `encodeDefaults` 를 켜거나 슬롯 기본값을 떼면 여기서 깨진다. + */ +class AfternoteUpdatePartialPatchWireTest { + private val json = NetworkModule.provideJson() + + private fun wire(payload: UpdateAfternoteInput): String = json.encodeToString(payload.toRequest()) + + /** + * 이슈 #1617 이 든 재현 경로의 반대편 — 제목만 고친 저장은 나머지를 **말하지 않는다.** + * 키 하나라도 되살아나면 에디터를 연 뒤 서버가 바뀐 경우 그 필드가 낡은 값으로 덮인다. + */ + @Test + fun `제목만 실은 수정은 카테고리와 제목 두 키만 나간다`() { + val body = wire(UpdateAfternoteInput(type = AfternoteType.SOCIAL_NETWORK, title = "새 제목")) + + assertEquals("""{"category":"SOCIAL","title":"새 제목"}""", body) + } + + /** 제목조차 안 건드렸으면 제목도 빠진다 — 서버가 `title` 생략을 계약으로 못박아 뒀다. */ + @Test + fun `아무것도 안 실으면 카테고리만 나간다`() { + val body = wire(UpdateAfternoteInput(type = AfternoteType.MEMORIAL)) + + assertEquals("""{"category":"PLAYLIST"}""", body) + } + + /** + * **곡만 고친 저장이 사진·영상을 지우면 안 된다.** + * + * 이 PR 이 처음 고쳤을 때 남아 있던 결함이 정확히 여기였다. `playlist` 를 한 덩어리로 판정해 + * 곡 변경만으로 세 슬롯을 함께 실었고, 기본값 없는 사진·영상 슬롯이 `null` 로 직렬화돼 + * `{"memorialPhotoUrl":null,"memorialVideo":null}` 이 삭제 지시로 나갔다. + */ + @Test + fun `곡만 바꾼 수정은 playlist 안에 songs 키만 남긴다`() { + val body = + wire( + UpdateAfternoteInput( + type = AfternoteType.MEMORIAL, + memorial = + MemorialPatchInput( + songs = listOf(MemorialSongPayload(title = "곡", artist = "가수", coverUrl = null)), + ), + ), + ) + + assertEquals( + """{"category":"PLAYLIST","playlist":{"songs":[{"title":"곡","artist":"가수"}]}}""", + body, + ) + } + + /** 곡을 전부 뺀 것은 삭제 지시다 — 빈 배열이 나가고, 미디어 슬롯은 여전히 빠져 있어야 한다. */ + @Test + fun `곡을 전부 빼면 빈 배열만 나가고 미디어 키는 없다`() { + val body = + wire( + UpdateAfternoteInput( + type = AfternoteType.MEMORIAL, + memorial = MemorialPatchInput(songs = emptyList()), + ), + ) + + assertEquals("""{"category":"PLAYLIST","playlist":{"songs":[]}}""", body) + } + + /** 사진을 지운 저장만 사진 슬롯에 명시적 null 을 싣는다 — 곡·영상 키는 나가지 않는다. */ + @Test + fun `영정 사진만 지우면 사진 슬롯에만 명시적 null 이 나간다`() { + val body = + wire( + UpdateAfternoteInput( + type = AfternoteType.MEMORIAL, + memorial = MemorialPatchInput(memorialPhotoUrl = FieldPatch.Set(null)), + ), + ) + + assertEquals("""{"category":"PLAYLIST","playlist":{"memorialPhotoUrl":null}}""", body) + } + + @Test + fun `추모 영상만 지우면 영상 슬롯에만 명시적 null 이 나간다`() { + val body = + wire( + UpdateAfternoteInput( + type = AfternoteType.MEMORIAL, + memorial = MemorialPatchInput(memorialVideo = FieldPatch.Set(null)), + ), + ) + + assertEquals("""{"category":"PLAYLIST","playlist":{"memorialVideo":null}}""", body) + } + + @Test + fun `미디어에 값을 실으면 종전과 같은 모양으로 나간다`() { + val body = + wire( + UpdateAfternoteInput( + type = AfternoteType.MEMORIAL, + memorial = + MemorialPatchInput( + 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( + UpdateAfternoteInput( + type = AfternoteType.SOCIAL_NETWORK, + credentials = AfternoteAccountCredentials(password = "새 비밀번호"), + ), + ) + + assertEquals("""{"category":"SOCIAL","credentials":{"password":"새 비밀번호"}}""", body) + } + + @Test + fun `아이디만 고치면 credentials 안에 id 키만 나간다`() { + val body = + wire( + UpdateAfternoteInput( + type = AfternoteType.SOCIAL_NETWORK, + credentials = AfternoteAccountCredentials(id = "새 아이디"), + ), + ) + + assertEquals("""{"category":"SOCIAL","credentials":{"id":"새 아이디"}}""", body) + } + + /** 「전부 삭제」는 「안 건드림」과 다른 지시다 — 빈 배열이 키를 달고 나가야 서버가 지운다. */ + @Test + fun `빈 목록은 생략되지 않고 빈 배열로 나간다`() { + val body = + wire( + UpdateAfternoteInput( + type = AfternoteType.GALLERY_AND_FILES, + processingMethods = emptyList(), + leaveMessageBlocks = emptyList(), + receiverIds = emptyList(), + ), + ) + + assertEquals( + """{"category":"GALLERY","actions":[],"leaveMessage":[],"receivers":[]}""", + body, + ) + } + + /** 수신자 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 = + wire( + UpdateAfternoteInput( + type = AfternoteType.BUSINESS, + title = "제목", + processingMethods = listOf("계정 삭제"), + leaveMessageBlocks = listOf(LeaveMessageBlock(title = "가족에게", body = "고마웠어")), + credentials = AfternoteAccountCredentials(id = "account", password = "pw"), + receiverIds = listOf(11L), + ), + ) + + assertEquals( + """{"category":"BUSINESS","title":"제목","actions":["계정 삭제"],""" + + """"leaveMessage":[{"title":"가족에게","body":"고마웠어"}],""" + + """"credentials":{"id":"account","password":"pw"},"receivers":[{"receiverId":11}]}""", + body, + ) + } +} 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/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). */ 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() 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/AfternoteUpdatePayload.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AfternoteUpdatePayload.kt deleted file mode 100644 index f35c77528b..0000000000 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/AfternoteUpdatePayload.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.afternote.feature.afternote.domain.model.author - -import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.domain.model.LeaveMessageBlock - -data class AfternoteUpdatePayload( - val type: AfternoteType, - val title: String, - val processingMethods: List? = null, - val leaveMessageBlocks: List = emptyList(), - val credentials: AfternoteAccountCredentials? = null, - val receivers: List? = null, - val memorial: MemorialWritePayload? = null, - /** - * 임시저장 여부를 바꿀 때만 값을 싣는다. null 이면 서버가 저장값을 유지하므로, - * 이어쓰기 중 «임시저장으로 계속» 은 null·true 어느 쪽이든 되고 «정식 등록» 만 false 를 명시한다. - */ - val isDraft: Boolean? = null, -) - -data class AfternoteAccountCredentials( - val id: String? = null, - val password: String? = null, -) - -data class ReceiverRefPayload( - val receiverId: Long, -) 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 17bd60e650..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 @@ -12,15 +12,16 @@ data class CreateMemorialPayload( ) /** - * 추억 노트 미디어·곡의 **폼 전체 스냅샷**. 「지금 이 노트가 가져야 할 상태」를 통째로 말한다. + * 추억 노트 미디어·곡의 **폼 전체 스냅샷** — **생성(POST) 전용**이다. + * 「지금 이 노트가 가져야 할 상태」를 통째로 말한다. * - * 수정 저장에서 미디어의 `null` 은 「모르겠다」가 아니라 「비어 있다 — 서버에 있으면 지워라」로 - * 나간다. 그래서 기본값을 두지 않는다: 슬롯마다 뜻을 적게 해, 일부만 아는 호출부가 나머지를 - * 조용히 지우는 일을 타입에서 막는다. 와이어에서 그 뜻이 어떻게 표현되는지는 - * `AfternotePlaylistRequestDto` 의 KDoc 에 있다 (#1596). + * 슬롯에 기본값을 두지 않는 것이 계약이다: 뜻을 적게 해, 일부만 아는 호출부가 나머지를 조용히 + * 비우는 일을 타입에서 막는다. 와이어에서 그 뜻이 어떻게 표현되는지는 + * `AfternotePlaylistRequestDto` 의 KDoc 에 있다 (#1596·#1599). * - * [songs] 도 같은 뜻이다 — 빈 목록은 「모르겠다」가 아니라 「곡이 하나도 없다」로 나가 서버 곡을 - * 전부 지운다. 와이어 표현만 다르다: 미디어는 JSON `null`, 곡은 **빈 배열**이다 (#1599). + * **수정(PATCH)에는 쓰지 않는다.** 수정은 「만진 슬롯만」 말해야 하는데 이 타입은 그것을 표현할 + * 수 없다 — 전체를 말하므로, 곡만 바꾼 저장도 사진·영상을 함께 실어 그 사이 다른 기기가 올린 + * 미디어를 지운다. 수정은 [MemorialPatchInput] 를 쓴다 (#1617). */ data class MemorialWritePayload( val memorialPhotoUrl: String?, @@ -33,6 +34,31 @@ data class MemorialVideoPayload( val thumbnailUrl: String? = null, ) +/** + * 추억 노트 미디어·곡의 **부분 수정** 지시 (#1617). + * + * [MemorialWritePayload] 와 결정적으로 다르다 — 저쪽은 「지금 이 노트가 가져야 할 상태」를 통째로 + * 말하고(생성), 이쪽은 「내가 만진 슬롯만」 말한다(수정). 수정에서 전체 스냅샷을 실으면, 에디터를 + * 연 뒤 서버가 바뀐 경우 만진 적 없는 슬롯이 낡은 값으로 덮인다 — 곡만 바꾼 저장이 그 사이 올라온 + * 영정사진·추모 영상을 명시적 `null` 로 지우는 사고가 그것이다. + * + * 슬롯마다 표현이 다른 것은 서버 계약이 다르기 때문이다: + * - [memorialPhotoUrl]·[memorialVideo] 는 **키 유무**로 유지/삭제를 가르므로 [FieldPatch] 가 필요하다. + * - [songs] 는 서버가 `songs != null` 만 보므로 `null`(안 건드림)과 빈 배열(전부 삭제)로 충분하다. + */ +data class MemorialPatchInput( + 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/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/UpdateAfternoteInput.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/UpdateAfternoteInput.kt new file mode 100644 index 0000000000..3fcc496f80 --- /dev/null +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/author/UpdateAfternoteInput.kt @@ -0,0 +1,66 @@ +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`), 같으면 아무것도 + * 바꾸지 않는다. 카테고리는 수정 화면에서 애초에 바꿀 수 없다. + * + * **판정 단위는 서버가 반영하는 단위와 같아야 한다.** 객체를 통째로 「바뀌었다/아니다」로 재면, 그 + * 객체 안에서 안 건드린 형제 슬롯이 낡은 값째 함께 실려 나간다. 그래서 [credentials] 는 id·비밀번호를 + * 따로 재고(서버도 `CredentialsRelationSupport.update` 가 둘을 독립으로 갱신한다), [memorial] 은 + * 슬롯별 [FieldPatch] 를 든 [MemorialPatchInput] 로 말한다 (#1617). + */ +data class UpdateAfternoteInput( + val type: AfternoteType, + val title: String? = null, + val processingMethods: List? = null, + val leaveMessageBlocks: List? = null, + val credentials: AfternoteAccountCredentials? = null, + /** + * 수신자 id 목록. 위 표의 세 상태를 그대로 따른다 — `null` 은 안 건드림, 빈 목록은 수신자 전부 + * 해제, 값은 이 목록으로 통째 교체다. 원소 순서·중복은 받은 그대로 옮기며, wire 의 + * `receivers: [{receiverId}]` 로 감싸는 것은 data 경계의 몫이다 (#1617). + */ + val receiverIds: List? = null, + val memorial: MemorialPatchInput? = null, + /** + * 임시저장 여부를 바꿀 때만 값을 싣는다. null 이면 서버가 저장값을 유지하므로, + * 이어쓰기 중 «임시저장으로 계속» 은 null·true 어느 쪽이든 되고 «정식 등록» 만 false 를 명시한다. + */ + val isDraft: Boolean? = 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/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..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 @@ -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 @@ -14,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.UpdateAfternoteInput import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.flow.take @@ -38,12 +37,12 @@ class FakeAfternoteRepositoryTest { initialDetails = mapOf(detail.id to detail), ) val payload = - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.SOCIAL_NETWORK, title = "수정 서비스", processingMethods = listOf("계정 보존"), credentials = AfternoteAccountCredentials(id = "new@test.local"), - receivers = listOf(ReceiverRefPayload(7L)), + receiverIds = listOf(7L), ) val result = repository.update(item.id, payload) @@ -149,7 +148,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 82d846199d..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 @@ -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 @@ -13,7 +12,10 @@ 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.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 @@ -47,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) @@ -60,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() @@ -119,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) } @@ -169,9 +171,13 @@ class FakeAfternoteRepository( } } -private fun ListItem.updatedWith(payload: AfternoteUpdatePayload): ListItem = +/** + * 수정은 **부분 갱신**이라 `null` 슬롯은 「안 건드림」이다 — 서버와 같이 기존 값을 남긴다 (#1617). + * 여기서 덮어써 버리면 페이로드가 필드를 뺐는지 여부를 테스트가 구분하지 못한다. + */ +private fun ListItem.updatedWith(payload: UpdateAfternoteInput): ListItem = copy( - serviceName = payload.title, + serviceName = payload.title ?: serviceName, type = payload.type, account = account.updatedWith(payload.credentials), ) @@ -186,19 +192,19 @@ 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 = 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 = payload.leaveMessageBlocks ?: leaveMessageBlocks, 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 @@ -229,13 +235,19 @@ private fun DetailContent.updatedWith(payload: AfternoteUpdatePayload): DetailCo if (memorial == null) { previous ?: DetailContent.Memorial(songs = emptyList(), media = MemorialMedia(null, null, null)) } else { + // 서버와 같이 슬롯별로 반영한다. 만지지 않은 필드는 기존 값을 유지한다 (#1617). + val previousMedia = previous?.media + val video = memorial.memorialVideo.resolve(previousMedia?.toVideoPayload()) DetailContent.Memorial( - 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, ), ) } @@ -251,3 +263,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/editor/AfternoteEditorFormMapper.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt index 85664983ff..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 @@ -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 @@ -11,10 +10,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.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 import com.afternote.feature.afternote.presentation.editor.message.EditorMessageTextBlock @@ -219,71 +221,205 @@ internal object AfternoteEditorFormMapper { receiverIds = selectedReceiverIds, ) + /** + * 수정 요청 페이로드를 조립한다 — **[baseline] 과 달라진 슬롯만 싣는다** (#1617). + * + * 폼 전체 스냅샷을 매번 통째로 보내면, 에디터를 연 뒤 서버가 바뀐 경우 사용자가 만진 적도 없는 + * 필드가 낡은 값으로 덮인다. 서버는 「키 없음 = 유지」로 읽으므로, 안 건드린 슬롯을 아예 빼는 + * 것으로 그 사고를 구조적으로 없앤다. + * + * **판정 단위는 서버가 반영하는 단위와 같다.** 객체를 통째로 재면 그 안에서 안 건드린 형제 + * 슬롯이 낡은 값째 딸려 나가므로, 계정 정보는 id·비밀번호를 따로, 플레이리스트는 사진·영상·곡을 + * 따로 잰다. + * + * [baseline] 은 **필수**다. 기준 없이 조립할 수 있게 두면 상세 조회가 실패한 화면에서 빈 폼이 + * 그대로 전량 PATCH 로 나가 이 이슈가 잡으려던 삭제 사고를 되풀이한다. 기준이 없을 때 저장을 + * 막는 것은 호출부([AfternoteEditorViewModel])의 몫이다. + */ 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, - ), - ) - } - - AfternoteType.GALLERY_AND_FILES, AfternoteType.SOCIAL_NETWORK, AfternoteType.BUSINESS -> { - buildProcessingMethodsUpdatePayload(type, payload, selectedReceiverIds) - } - + memorialMediaUrls: MemorialMediaUrls, + baseline: AfternoteEditorSnapshot, + ): UpdateAfternoteInput { + if (type == AfternoteType.ESTATE) { // placeholder 카테고리는 Validator 에서 차단됨. 도달 시 호출자 버그. - AfternoteType.ESTATE -> { - error("Unimplemented type cannot be saved: $type") - } + error("Unimplemented type cannot be saved: $type") } + val current = + buildEditorSnapshot( + type = type, + payload = payload, + selectedReceiverIds = selectedReceiverIds, + 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), + ) + } + + /** 계정 정보는 서버가 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) + } + + /** 플레이리스트는 슬롯 셋을 따로 재고, 하나도 안 바뀌었으면 `playlist` 키 자체를 내보내지 않는다. */ + private fun diffMemorial( + current: AfternoteEditorSnapshot, + baseline: AfternoteEditorSnapshot, + ): MemorialPatchInput? { + if (current.type != AfternoteType.MEMORIAL) return null + val patch = + MemorialPatchInput( + 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 } + } /** - * 처리 방법 기반 카테고리(SOCIAL·BUSINESS·GALLERY) 공용 update payload — - * [AfternoteUpdatePayload.processingMethods] 를 채우고 계정형(SOCIAL·BUSINESS)만 credentials 를 싣는다. - * MEMORIAL 은 [AfternoteUpdatePayload.memorial] 기반이라 [buildUpdatePayload] 의 별도 분기. + * 수정 진입 시 받은 상세를 **「서버가 지금 들고 있는 값」** 스냅샷으로 옮긴다. + * + * [buildEditorSnapshot] 이 만드는 현재 폼 스냅샷과 같은 어휘라 슬롯끼리 바로 견줄 수 있다. + * 프리필([buildEditorFormPrefill])을 거치지 않고 [Detail] 에서 직접 만드는 이유는, 프리필이 + * 화면 표시용으로 값을 한 번 가공하기 때문이다 — 비교 기준은 가공 전 원본이어야 한다. */ - private fun buildProcessingMethodsUpdatePayload( + 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) + }, + ) + } + + /** 현재 폼을 기준 스냅샷과 **같은 어휘**로 옮긴다 — 그래야 슬롯끼리 견줄 수 있다. */ + private fun buildEditorSnapshot( type: AfternoteType, payload: RegisterAfternotePayload, selectedReceiverIds: List, - ): AfternoteUpdatePayload { - val processingMethods = payload.processingMethods + playlistSongs: List, + memorialMediaUrls: 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, - processingMethods = processingMethods.ifEmpty { null }, - 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) 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 + }, ) } + + 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 }) + } } +/** + * 현재 폼과 서버 원본을 견주기 위한 **같은 어휘의 스냅샷** (#1617). + * + * 「무엇을 보낼까」가 아니라 「무엇이 달라졌나」만 판정하는 자료다. 그래서 wire 표현(키 생략·명시적 + * null·빈 배열)을 담지 않고, 슬롯마다 의미값 하나씩만 든다. 카테고리에 없는 슬롯은 `null` 로 비워 + * 양쪽이 「둘 다 없음」으로 맞아떨어지게 한다. + */ +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?, +) + +/** + * 남기실 말씀을 비교용 정규형으로 좁힌다. + * + * 폼에서 도메인으로 옮길 때 이미 앞뒤 공백을 떼므로([toLeaveMessageBlocks]), 서버 원본도 같은 모양으로 + * 맞춰야 「공백만 다른 같은 값」이 변경으로 잡히지 않는다. 본문이 빈 블록은 서버가 400 으로 거절하고 + * (`AfternoteValidationCommons.validateLeaveMessage`) 응답 파싱도 걸러 내므로 양쪽에 존재하지 않는다 — + * 그래서 여기서 원소를 **버리지는 않는다.** 버리기 시작하면 「지웠다」가 상쇄돼 사라진다. + */ +private fun List.normalizedForDiff(): List = + map { LeaveMessageBlock(title = it.title?.trim()?.ifEmpty { null }, body = it.body.trim()) } + private fun LeaveMessageBlock.toEditorBlock(): EditorMessageTextBlock = EditorMessageTextBlock( title = title.orEmpty(), 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 3ede4b6f97..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 @@ -487,6 +487,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) @@ -510,6 +518,7 @@ class AfternoteEditorViewModel selectedReceiverIds = selectedReceiverIds, playlistSongs = playlistSongs, memorialMedia = memorialMediaForSave, + updateBaseline = updateBaseline, ).fold( onSuccess = { command -> executeSaveCommand(command).fold( @@ -570,6 +579,7 @@ class AfternoteEditorViewModel selectedReceiverIds: List, playlistSongs: List, memorialMedia: SaveAfternoteMemorialMedia, + updateBaseline: AfternoteEditorSnapshot?, ): Result { val resolved = resolveMemorialMediaForSave( @@ -589,12 +599,17 @@ class AfternoteEditorViewModel payload = payload, selectedReceiverIds = selectedReceiverIds, playlistSongs = playlistSongs, - memorialMedia = + memorialMediaUrls = MemorialMediaUrls( memorialVideoUrl = resolved.resolvedVideoUrl, memorialThumbnailUrl = memorialMedia.memorialVideo.displayed?.thumbnailUrl, memorialPhotoUrl = resolved.resolvedMemorialPhotoUrl, ), + // saveAfternote 가 기준 없는 수정을 이미 막았다 — 여기 도달하면 반드시 있다. + baseline = + checkNotNull(updateBaseline) { + "수정 저장에는 기준 스냅샷이 필요합니다" + }, ) SaveAfternoteCommand.Update(id = editingId, payload = updatePayload) } else { @@ -650,6 +665,9 @@ class AfternoteEditorViewModel it.copy( originalType = prefill.type, pendingPrefill = prefill, + // 화면에 뿌릴 prefill 과 별개로, 가공 전 원본을 저장 때 견줄 기준으로 남긴다. + // 이 값은 폼 변경을 따라가지 않는다 — 따라가면 비교할 대상이 사라진다 (#1617). + updateBaseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail), ) } }.onFailure { e -> @@ -735,6 +753,13 @@ class AfternoteEditorViewModel val pendingThumbnailUrl: String? = null, val memorialThumbnailRetryToken: Int = 0, val pendingPrefill: EditorFormPrefill? = null, + /** + * 수정 진입 시 받은 상세를 그대로 옮긴 **pristine baseline** (#1617). + * + * 저장 시 현재 폼과 견줘 **달라진 필드만** 요청에 싣는 기준이다. `null` 이면 기준이 없다는 + * 뜻이라(신규 작성이거나 상세 로드 실패) 종전처럼 전량을 싣는다. + */ + val updateBaseline: AfternoteEditorSnapshot? = null, ) private fun InternalState.toUiState(): AfternoteEditorUiState = 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 28e94b98a1..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.ReceiverRefPayload 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 @@ -81,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()) } @@ -139,15 +145,16 @@ 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) + // 계정 정보 안에서도 슬롯별로 갈린다: 서버가 id·비밀번호를 따로 갱신하므로, 안 고친 + // 비밀번호를 함께 실으면 그 사이 다른 기기가 바꾼 값이 낡은 값으로 되돌아간다 (#1617). + assertNull("비밀번호는 고친 적이 없다", payload.credentials?.password) + // 안 만진 나머지 — 키째 빠져야 서버가 기존 값을 유지한다. + assertNull("제목을 고친 적이 없다", payload.title) + assertNull("남기실 말씀을 고친 적이 없다", payload.leaveMessageBlocks) + 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/AfternoteEditorMissingBaselineTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt new file mode 100644 index 0000000000..3603e8e72c --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt @@ -0,0 +1,173 @@ +package com.afternote.feature.afternote.presentation.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.editor.model.RegisterAfternotePayload +import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute +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() + // 화면이 prefill 을 폼에 반영했다는 통보. 이걸 빼면 저장이 «아직 읽는 중» + // ([AfternoteEditorError.PrefillNotReady]) 으로 막혀 기준 판정까지 가지 못한다 (#705). + viewModel.onPrefillConsumed() + 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, + ) + } + + /** + * 저장 차단 안내는 저장 실패 문구와 달라야 한다 — 사용자가 할 일이 다르다. + * + * 문구는 #705 가 세운 prefill 조회 실패 문구를 함께 쓴다. 「기준이 없다」와 「기존 내용을 + * 읽지 못했다」는 사용자에게 같은 사실이고 할 일도 같다 — 다시 불러오는 것이다. + */ + @Test + fun `기준 없음은 저장 실패와 다른 문구로 안내한다`() { + assertEquals( + R.string.afternote_editor_prefill_load_failed, + 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( + route = AfternoteRoute.EditorFlowRoute(itemId = EDIT_ID, initialType = AfternoteType.GALLERY_AND_FILES), + 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/editor/AfternoteEditorPartialUpdateTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt new file mode 100644 index 0000000000..8854b9b1ae --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPartialUpdateTest.kt @@ -0,0 +1,347 @@ +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.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.playlist.DetailSong +import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia +import com.afternote.feature.afternote.presentation.editor.memorial.Song +import com.afternote.feature.afternote.presentation.editor.message.EditorMessageTextBlock +import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 수정 요청이 **사용자가 만진 슬롯만** 싣는지 고정한다 (#1617). + * + * 종전에는 폼이 든 전체 스냅샷이 매번 통째로 나갔다. 에디터를 연 뒤 서버가 바뀌면, 사용자가 건드린 + * 적도 없는 필드가 낡은 로컬 값으로 덮였다. + * + * 고정하는 축은 셋이다. + * 1. 안 건드린 슬롯은 페이로드에서 빠진다 → 요청에서 키째 빠진다 → 서버가 「유지」로 읽는다. + * 2. **비운 것은 안 건드린 것과 다르다** — 빈 목록·명시적 삭제는 그대로 실린다 (#1596·#1599). + * 3. **판정 단위는 서버가 반영하는 단위와 같다** — 계정 정보는 id·비밀번호를 따로, 플레이리스트는 + * 사진·영상·곡을 따로 잰다. 객체째 재면 안 건드린 형제 슬롯이 낡은 값으로 딸려 나간다. + */ +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( + 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), + detail: Detail = socialDetail, + ) = AfternoteEditorFormMapper.buildUpdatePayload( + type = AfternoteType.SOCIAL_NETWORK, + payload = payload, + selectedReceiverIds = selectedReceiverIds, + playlistSongs = emptyList(), + memorialMediaUrls = MemorialMediaUrls(), + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail), + ) + + @Test + fun `제목만 고치면 제목 말고는 아무 필드도 실리지 않는다`() { + val updated = buildSocialUpdate(payload = untouchedSocialPayload(serviceName = "새 제목")) + + assertEquals("새 제목", updated.title) + assertNull("처리 방법을 만진 적이 없다", updated.processingMethods) + assertNull("남기실 말씀을 만진 적이 없다", updated.leaveMessageBlocks) + assertNull("계정 정보를 만진 적이 없다", updated.credentials) + assertNull("수신자를 만진 적이 없다", updated.receiverIds) + assertNull("추억 노트가 아니므로 애초에 없다", updated.memorial) + } + + @Test + fun `아무것도 안 고치면 어느 필드도 실리지 않는다`() { + val updated = buildSocialUpdate() + + assertNull(updated.title) + assertNull(updated.processingMethods) + assertNull(updated.leaveMessageBlocks) + assertNull(updated.credentials) + assertNull(updated.receiverIds) + } + + /** 순서가 뜻을 갖지 않는 수신자는 정렬이 달라졌다고 「고쳤다」로 읽지 않는다. */ + @Test + fun `수신자 순서만 바뀐 것은 변경이 아니다`() { + val updated = buildSocialUpdate(selectedReceiverIds = listOf(22L, 11L)) + + assertNull(updated.receiverIds) + } + + @Test + fun `수신자를 실제로 지우면 남은 목록이 실린다`() { + val updated = buildSocialUpdate(selectedReceiverIds = listOf(11L)) + + assertEquals(listOf(11L), updated.receiverIds) + } + + @Test + fun `수신자를 전부 빼면 빈 목록이 실려 전부 삭제로 나간다`() { + val updated = buildSocialUpdate(selectedReceiverIds = emptyList()) + + assertEquals(emptyList(), updated.receiverIds) + } + + @Test + fun `처리 방법을 전부 빼면 빈 목록이 실려 전부 삭제로 나간다`() { + val updated = buildSocialUpdate(payload = untouchedSocialPayload(processingMethods = emptyList())) + + 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())) + + assertEquals(emptyList(), updated.leaveMessageBlocks) + } + + /** + * 서버가 id 와 비밀번호를 **따로** 갱신하므로(`CredentialsRelationSupport.update`), 안 고친 쪽은 + * 실으면 안 된다. 함께 실으면 그 사이 다른 기기가 바꾼 id 가 낡은 값으로 되돌아간다. + */ + @Test + 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) + } + + /** + * 에디터가 띄워 두는 빈 입력 칸은 도메인으로 넘어가기 전에 버려진다. 그 결과가 서버 원본과 + * 같다면 「고쳤다」가 아니다 — 여기서 흔들리면 저장할 때마다 남기실 말씀이 다시 실린다. + */ + @Test + fun `빈 입력 칸과 앞뒤 공백은 변경으로 세지 않는다`() { + val updated = + buildSocialUpdate( + payload = + untouchedSocialPayload( + messageBlocks = + listOf( + EditorMessageTextBlock(title = " 가족에게 ", body = " 고마웠어 ", isRegistered = true), + EditorMessageTextBlock(title = "", body = "", isRegistered = false), + ), + ), + ) + + assertNull(updated.leaveMessageBlocks) + } + + private fun buildMemorialUpdate( + serviceName: String = "추억 노트", + playlistSongs: List = + listOf(Song(selectionKey = "detail:0", title = "곡", artist = "가수", albumCoverUrl = null)), + memorialMediaUrls: 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, + memorialMediaUrls = memorialMediaUrls, + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(memorialDetail), + ) + + /** + * 이슈가 든 재현 경로 그대로다 — 곡을 건드리지 않은 「제목만 수정」이 곡을 지우면 안 된다. + * `playlist` 키가 아예 나가지 않아야 서버가 곡·사진·영상을 통째로 유지한다. + */ + @Test + fun `추억 노트도 제목만 고치면 플레이리스트를 말하지 않는다`() { + val updated = buildMemorialUpdate(serviceName = "새 제목") + + assertEquals("새 제목", updated.title) + 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 `곡을 전부 빼면 빈 곡 목록만 실리고 미디어는 말하지 않는다`() { + val updated = buildMemorialUpdate(playlistSongs = emptyList()) + + val memorial = requireNotNull(updated.memorial) { "곡을 지웠으므로 플레이리스트가 실려야 한다" } + assertTrue("곡을 지운 것은 명시적 삭제다", memorial.songs?.isEmpty() == true) + assertEquals(FieldPatch.Unchanged, memorial.memorialPhotoUrl) + assertEquals(FieldPatch.Unchanged, memorial.memorialVideo) + } + + /** 선택 키는 화면 전용 식별자라 서버로 나가지 않는다 — 재조회로 값이 달라져도 변경이 아니다. */ + @Test + fun `곡 선택 키가 달라도 곡 내용이 같으면 변경이 아니다`() { + val updated = + buildMemorialUpdate( + playlistSongs = + listOf(Song(selectionKey = "search:가수|곡|0", title = "곡", artist = "가수", albumCoverUrl = null)), + ) + + assertNull(updated.memorial) + } + + /** 사진만 지운 저장은 사진 슬롯만 삭제로 말하고, 곡·영상은 건드리지 않는다. */ + @Test + fun `영정 사진만 지우면 사진 슬롯만 삭제로 나간다`() { + val updated = + buildMemorialUpdate( + memorialMediaUrls = + MemorialMediaUrls( + memorialVideoUrl = "https://cdn.test/afternotes/video.mp4", + memorialThumbnailUrl = "https://cdn.test/afternotes/thumb.jpg", + memorialPhotoUrl = null, + ), + ) + + val memorial = requireNotNull(updated.memorial) { "사진을 지웠으므로 플레이리스트가 실려야 한다" } + assertEquals(FieldPatch.Set(null), memorial.memorialPhotoUrl) + assertNull("곡을 건드린 적이 없다", memorial.songs) + assertEquals("영상을 건드린 적이 없다", FieldPatch.Unchanged, memorial.memorialVideo) + } + + @Test + fun `추모 영상만 바꾸면 영상 슬롯만 실린다`() { + val updated = + buildMemorialUpdate( + memorialMediaUrls = + 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/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt new file mode 100644 index 0000000000..bf3472d2fb --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -0,0 +1,139 @@ +package com.afternote.feature.afternote.presentation.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.editor.model.EditorContentPrefill +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute +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( + route = AfternoteRoute.EditorFlowRoute(itemId = EDIT_ID, initialType = AfternoteType.GALLERY_AND_FILES), + savedStateHandle = + SavedStateHandle( + mapOf( + "initialType" to AfternoteType.GALLERY_AND_FILES, + "itemId" to EDIT_ID, + "editor_form_snapshot_v5" 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/editor/AfternoteEditorServerMediaDeleteSaveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt index de9f2b6a54..a8ba6767a2 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt @@ -4,7 +4,7 @@ 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.model.author.MemorialSongPayload +import com.afternote.feature.afternote.domain.model.author.FieldPatch 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.MediaInput @@ -81,12 +81,9 @@ class AfternoteEditorServerMediaDeleteSaveTest { val updatePayload = repository.updateCalls.single().second val memorial = requireNotNull(updatePayload.memorial) - assertNull(memorial.memorialPhotoUrl) - assertNull(memorial.memorialVideo) - assertEquals( - listOf(MemorialSongPayload(title = "배경음악", artist = "작곡가", coverUrl = "https://cdn.test/cover.jpg")), - memorial.songs, - ) + assertEquals(FieldPatch.Set(null), memorial.memorialPhotoUrl) + assertEquals(FieldPatch.Set(null), memorial.memorialVideo) + assertNull(memorial.songs) // Unchanged songs remain on the server. assertEquals(AFTERNOTE_ID, first.uiState.value.savedId) val updatedMedia = repository.details.getValue(AFTERNOTE_ID).memorialMedia() @@ -135,9 +132,9 @@ class AfternoteEditorServerMediaDeleteSaveTest { assertEquals(2, repository.updateCalls.size) repository.updateCalls.forEach { (_, payload) -> val memorial = requireNotNull(payload.memorial) - assertNull(memorial.memorialPhotoUrl) - assertNull(memorial.memorialVideo) - assertTrue(memorial.songs.isNotEmpty()) + assertEquals(FieldPatch.Set(null), memorial.memorialPhotoUrl) + assertEquals(FieldPatch.Set(null), memorial.memorialVideo) + assertNull(memorial.songs) } } 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 37745e93da..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 @@ -3,6 +3,11 @@ 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.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.MemorialMedia import com.afternote.feature.afternote.presentation.editor.message.EditorMessageTextBlock import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editor.state.AfternoteTypeForm @@ -93,7 +98,11 @@ class EditorLeaveMessageBlocksTest { ), selectedReceiverIds = emptyList(), playlistSongs = emptyList(), - memorialMedia = MemorialMediaUrls(), + memorialMediaUrls = MemorialMediaUrls(), + // 여기서 보는 것은 블록을 *어떻게 빚는가* 다. 말씀이 없던 노트를 기준으로 삼아 + // 「달라졌다」가 확실하게 성립하게 두고, 빚어진 모양만 본다 — 안 건드린 필드를 빼는 + // 축은 AfternoteEditorPartialUpdateTest 가 따로 고정한다 (#1617). + baseline = emptyBaseline(AfternoteType.SOCIAL_NETWORK), ) assertEquals( @@ -130,7 +139,10 @@ class EditorLeaveMessageBlocksTest { payload = payloadOf(EditorMessageTextBlock(title = "가족에게", body = "잘 지내")), selectedReceiverIds = emptyList(), playlistSongs = emptyList(), - memorialMedia = MemorialMediaUrls(), + memorialMediaUrls = MemorialMediaUrls(), + // 여기서 보는 것은 블록을 *어떻게 빚는가* 다. 말씀이 없던 노트를 기준으로 삼아 + // 「달라졌다」가 확실하게 성립하게 두고, 빚어진 모양만 본다 (#1617). + baseline = emptyBaseline(AfternoteType.MEMORIAL), ) assertEquals( @@ -170,4 +182,32 @@ 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( + songs = emptyList(), + media = MemorialMedia(null, null, null), + ) + } + + else -> { + DetailContent.SocialNetwork( + credentials = DetailCredentials(id = "", password = ""), + processingMethods = emptyList(), + ) + } + }, + ), + ) }