diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContract.kt index 0237e5c269..84d59093b1 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContract.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContract.kt @@ -103,6 +103,7 @@ internal sealed interface AfternoteEditorIntent : MviIntent { val payload: RegisterAfternotePayload, val selectedReceiverIds: List, val memorialMedia: SaveAfternoteMemorialMedia, + val asDraft: Boolean = false, ) : AfternoteEditorIntent data object RetryPrefill : AfternoteEditorIntent 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 4763274635..2259a428dd 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 @@ -10,6 +10,8 @@ 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.DraftContent +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.MemorialPatchInput import com.afternote.feature.afternote.domain.model.author.MemorialSongPayload @@ -51,6 +53,85 @@ internal object AfternoteEditorFormMapper { }, ) + /** + * 임시저장 상세 → 에디터 프리필 (#808). + * + * 발행 상세와 달리 종류별 값이 아직 안 담긴 채로 온다. 미작성 값은 빈 입력칸으로 옮긴다. + * [DraftContent]의 각 종류가 가진 입력만 해당 폼에 담는다. + */ + fun buildEditorFormPrefill(draft: DraftDetail): EditorFormPrefill = + EditorFormPrefill( + content = draft.toEditorContentPrefill(), + leaveMessageBlocks = draft.leaveMessageBlocks.map(LeaveMessageBlock::toEditorBlock), + receivers = + draft.receivers.map { receiver -> + AfternoteEditorReceiver( + id = receiver.receiverId, + name = receiver.name, + label = receiver.relation, + ) + }, + ) + + private fun DraftDetail.toEditorContentPrefill(): EditorContentPrefill = + when (val draftContent = content) { + is DraftContent.SocialNetwork -> { + EditorContentPrefill.SocialNetwork( + serviceName = serviceName, + credentials = draftContent.credentials.toEditorCredentialsPrefill(), + processingMethods = draftContent.processingMethods.toProcessingMethodItems(), + ) + } + + is DraftContent.Business -> { + EditorContentPrefill.Business( + serviceName = serviceName, + credentials = draftContent.credentials.toEditorCredentialsPrefill(), + processingMethods = draftContent.processingMethods.toProcessingMethodItems(), + ) + } + + is DraftContent.Gallery -> { + EditorContentPrefill.Gallery( + serviceName = serviceName, + processingMethods = draftContent.processingMethods.toProcessingMethodItems(), + ) + } + + is DraftContent.Memorial -> { + EditorContentPrefill.Memorial( + videoUrl = draftContent.media.videoUrl, + thumbnailUrl = draftContent.media.thumbnailUrl, + photoUrl = draftContent.media.photoUrl, + playlistSongs = + draftContent.songs.mapIndexed { index, song -> + Song( + selectionKey = "draft:$index", + title = song.title, + artist = song.artist, + albumCoverUrl = song.coverUrl, + ) + }, + ) + } + + DraftContent.Estate -> { + EditorContentPrefill.Estate + } + } + + /** + * 발행·임시저장 공용. 미작성 계정 정보는 빈 입력칸으로 연다. + * 발행 필수값의 검증은 상세 계약을 담당하는 data 매퍼의 몫이다. + * + * 널 허용 수신자 하나로 합친 이유는 JVM 소거다 — non-null 판과 시그니처가 같아 공존할 수 없다. + */ + private fun DetailCredentials?.toEditorCredentialsPrefill() = + EditorCredentialsPrefill( + id = this?.id.orEmpty(), + password = this?.password.orEmpty(), + ) + private fun DetailContent.toEditorContentPrefill(serviceName: String): EditorContentPrefill = when (this) { is DetailContent.SocialNetwork -> { @@ -98,12 +179,6 @@ internal object AfternoteEditorFormMapper { } } - private fun DetailCredentials.toEditorCredentialsPrefill() = - EditorCredentialsPrefill( - id = id, - password = password, - ) - private fun List.toProcessingMethodItems(): List = mapIndexed { index, text -> ProcessingMethodItem( @@ -203,6 +278,23 @@ internal object AfternoteEditorFormMapper { } } + /** + * 만들어 둔 생성 입력에 임시저장 여부만 얹는다 (#808). + * + * 종류별 빌더마다 인자를 늘리지 않는 이유는 그 값이 «무엇을 담았나» 가 아니라 «어느 버튼으로 저장하나» 라서다 — + * 폼 내용과 무관하고, 저장 순간에만 정해진다. + */ + fun withDraft( + input: CreateAfternoteInput, + isDraft: Boolean, + ): CreateAfternoteInput = + when (input) { + is CreateAfternoteInput.Social -> CreateAfternoteInput.Social(input.payload.copy(isDraft = isDraft)) + is CreateAfternoteInput.Business -> CreateAfternoteInput.Business(input.payload.copy(isDraft = isDraft)) + is CreateAfternoteInput.Gallery -> CreateAfternoteInput.Gallery(input.payload.copy(isDraft = isDraft)) + is CreateAfternoteInput.Memorial -> CreateAfternoteInput.Memorial(input.payload.copy(isDraft = isDraft)) + } + private fun buildAccountCreatePayload( payload: RegisterAfternotePayload, processingMethods: List, @@ -400,6 +492,60 @@ internal object AfternoteEditorFormMapper { } } + /** 미작성 값도 표시용 기본값으로 바꾸기 전 서버 상세 그대로 비교 기준에 보존한다. */ + fun buildUpdateBaseline(detail: DraftDetail): AfternoteEditorSnapshot { + val title = detail.serviceName + val leaveMessageBlocks = detail.leaveMessageBlocks.normalizedForDiff() + val receiverIds = detail.receivers.map { it.receiverId } + return when (val content = detail.content) { + is DraftContent.SocialNetwork -> { + SocialNetworkSnapshot( + title = title, + leaveMessageBlocks = leaveMessageBlocks, + credentials = content.credentials?.toCredentialsSnapshot() ?: CredentialsSnapshot(id = null, password = null), + processingMethods = content.processingMethods, + receiverIds = receiverIds, + ) + } + + is DraftContent.Business -> { + BusinessSnapshot( + title = title, + leaveMessageBlocks = leaveMessageBlocks, + credentials = content.credentials?.toCredentialsSnapshot() ?: CredentialsSnapshot(id = null, password = null), + processingMethods = content.processingMethods, + receiverIds = receiverIds, + ) + } + + is DraftContent.Gallery -> { + GallerySnapshot( + title = title, + leaveMessageBlocks = leaveMessageBlocks, + processingMethods = content.processingMethods, + receiverIds = receiverIds, + ) + } + + is DraftContent.Memorial -> { + MemorialSnapshot( + title = title, + leaveMessageBlocks = leaveMessageBlocks, + photoUrl = content.media.photoUrl?.ifBlank { null }, + video = content.media.toVideoPayload(), + songs = + content.songs.map { song -> + MemorialSongPayload(title = song.title, artist = song.artist, coverUrl = song.coverUrl) + }, + ) + } + + DraftContent.Estate -> { + EstateSnapshot(title = title, leaveMessageBlocks = leaveMessageBlocks) + } + } + } + /** 현재 폼을 기준 스냅샷과 **같은 어휘**로 옮긴다 — 그래야 슬롯끼리 견줄 수 있다. */ private fun buildEditorSnapshot( type: AfternoteType, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidator.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidator.kt index 391194d634..9f6cc5761b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidator.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidator.kt @@ -10,11 +10,29 @@ import com.afternote.feature.afternote.presentation.editor.state.EditorFormState * * 카테고리별 필수 입력 조건을 모두 검사한다. 누락이 하나면 해당 필드 오류를, 둘 이상이면 * [AfternoteValidationError.MULTIPLE_REQUIRED_FIELDS]를 반환한다. + * + * ### 임시저장은 «전부 건너뛰기» 가 아니다 (#808) + * + * 서버가 완화하는 것은 **카테고리 전략의 필수값(`credentials`·`playlist`)뿐**이다. 그 위의 검증은 + * `isDraft` 와 무관하게 그대로 돈다 — BE `AfternoteValidator.validateCreateRequest` 가 + * `validateLeaveMessage` 를 전략보다 **먼저** 부르고, `title` 은 `AfternoteCreateRequest` 의 + * `@NotBlank` 라 Bean Validation 단계에서 걸린다. + * + * | 검증 | 임시저장 | 근거 | + * |---|---|---| + * | [AfternoteValidationError.UNIMPLEMENTED_TYPE] | **막는다** | 서버 이전 문제다. 통과시키면 `AfternoteEditorFormMapper` 의 `error(...)` 에 도달해 앱이 죽는다 | + * | [AfternoteValidationError.TITLE_REQUIRED] | **막는다** | `AfternoteCreateRequest.title` 이 `@NotBlank` | + * | [AfternoteValidationError.LEAVE_MESSAGE_BODY_REQUIRED] | **막는다** | `AfternoteValidationCommons.validateLeaveMessage` 가 본문 빈 블록에 400 | + * | [AfternoteValidationError.ACCOUNT_CREDENTIALS_REQUIRED] | 건너뛴다 | 서버가 임시저장에서 완화하는 바로 그 축 | + * + * 막지 않으면 「임시저장을 눌렀는데 400 이 나고 작성분이 통째로 날아감」이 된다. 임시저장은 + * «미완성을 보존하는» 수단이므로 **서버가 받아 주는 범위 안에서만** 느슨해야 한다. */ internal object AfternoteEditorValidator { fun validate( form: EditorFormState, payload: RegisterAfternotePayload, + asDraft: Boolean = false, ): AfternoteValidationError? { val type = form.selectedType // 미구현 placeholder 카테고리는 입력 상태와 무관하게 저장 불가 — 개별 필드 검증(서비스명 등)보다 먼저 차단해 @@ -28,24 +46,26 @@ internal object AfternoteEditorValidator { if (payload.messageBlocks.any { it.title.isNotBlank() && it.body.isBlank() }) { errors += AfternoteValidationError.LEAVE_MESSAGE_BODY_REQUIRED } - when (type) { - // 필수 판정 기준은 서버 계약(정식 등록 타이트 검증)이다 — 계정형은 계정 정보만 서버가 요구하고, - // 처리 방법(actions)·수신자는 서버도 「생략 또는 빈 배열 가능」인 선택 항목이다. - AfternoteType.SOCIAL_NETWORK, AfternoteType.BUSINESS -> { - if (payload.accountId.isBlank() || payload.password.isBlank()) { - errors += AfternoteValidationError.ACCOUNT_CREDENTIALS_REQUIRED + // 여기서부터가 «정식 등록에서만» 막는 축이다 — 서버가 임시저장에서 완화하는 범위와 같다. + if (!asDraft) { + when (type) { + // 필수 판정 기준은 서버 계약(정식 등록 타이트 검증)이다 — 계정형은 계정 정보만 서버가 요구하고, + // 처리 방법(actions)·수신자는 서버도 「생략 또는 빈 배열 가능」인 선택 항목이다. + AfternoteType.SOCIAL_NETWORK, AfternoteType.BUSINESS -> { + if (payload.accountId.isBlank() || payload.password.isBlank()) { + errors += AfternoteValidationError.ACCOUNT_CREDENTIALS_REQUIRED + } } - } - AfternoteType.GALLERY_AND_FILES -> {} + AfternoteType.GALLERY_AND_FILES -> {} - // 서버는 정식 등록 시 추모 플레이리스트 곡을 필수로 검증한다(PLAYLIST_SONGS_REQUIRED) — - // 확정 안내 문구가 없어 FE 검증 신설은 #1389 에서 다룬다. - AfternoteType.MEMORIAL -> {} + // 서버는 정식 등록 시 추모 플레이리스트 곡을 필수로 검증한다(PLAYLIST_SONGS_REQUIRED) — + // 확정 안내 문구가 없어 FE 검증 신설은 #1389 에서 다룬다. + AfternoteType.MEMORIAL -> {} - // ESTATE 는 디자인 확정 전 placeholder 만 노출. UI 자체에서 입력이 막혀 있지만 - // 안전망으로 Validator 에서도 저장을 차단한다. - AfternoteType.ESTATE -> {} + // ESTATE 는 위에서 이미 반환했다 — 여기 도달하지 않는다. + AfternoteType.ESTATE -> {} + } } return when (errors.size) { 0 -> null 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 0147b195f2..ae257e2c46 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 @@ -336,7 +336,7 @@ internal class AfternoteEditorViewModel } is AfternoteEditorIntent.Save -> { - saveAfternote(intent.payload, intent.selectedReceiverIds, intent.memorialMedia) + saveAfternote(intent.payload, intent.selectedReceiverIds, intent.memorialMedia, intent.asDraft) } AfternoteEditorIntent.RetryPrefill -> { @@ -696,10 +696,17 @@ internal class AfternoteEditorViewModel errorReporter.recordAfternoteFailure(AfternoteFailureStage.MEMORIAL_CAPTURE_LAUNCH, throwable) } + /** + * @param asDraft 임시저장으로 저장한다 (#808). 검증을 **통째로** 건너뛰지는 않는다 — 서버가 완화하는 + * 것은 카테고리 전략의 `credentials`·`playlist` 뿐이고 제목·남기실 말씀 본문은 그대로 400 이 난다. + * 그 경계는 [AfternoteEditorValidator] 의 KDoc 에 표로 있다. 통째로 건너뛰면 ESTATE 차단까지 + * 사라져 `AfternoteEditorFormMapper` 의 `error(...)` 로 앱이 죽는다. + */ private fun saveAfternote( payload: RegisterAfternotePayload, selectedReceiverIds: List, memorialMedia: SaveAfternoteMemorialMedia, + asDraft: Boolean = false, ) { val editorState = currentState if (editorState.isSaving) return @@ -732,6 +739,7 @@ internal class AfternoteEditorViewModel AfternoteEditorValidator.validate( form = form, payload = payload, + asDraft = asDraft, ) if (validationError != null) { dispatch(AfternoteEditorReducerEvent.ErrorRaised(AfternoteEditorError.Validation(validationError))) @@ -770,6 +778,7 @@ internal class AfternoteEditorViewModel selectedReceiverIds = selectedReceiverIds, playlistSongs = playlistSongs, memorialMedia = memorialMediaForSave, + asDraft = asDraft, updateBaseline = updateBaseline, ).fold( onSuccess = { command -> @@ -792,6 +801,7 @@ internal class AfternoteEditorViewModel selectedReceiverIds: List, playlistSongs: List, memorialMedia: SaveAfternoteMemorialMedia, + asDraft: Boolean, updateBaseline: AfternoteEditorSnapshot?, ): Result { val resolved = @@ -820,7 +830,13 @@ internal class AfternoteEditorViewModel "수정 저장에는 기준 스냅샷이 필요합니다" }, ) - SaveAfternoteCommand.Update(id = editingId, payload = updatePayload) + // 수정에서 isDraft 는 «어느 버튼으로 저장했나» 를 말하는데, **발행 완료분을 임시저장으로 + // 되돌리지는 않는다.** true 를 실으면 그 애프터노트가 홈 목록(발행분만)에서 사라지고 + // 임시저장 목록·이어쓰기는 #1792·#1791 로 빠져 있어 되찾을 경로가 없다. 그래서 원래 + // 임시저장이던 것(route.isDraft)에만 true 를 싣고, 발행분에는 생략해 저장값을 유지한다. + // 등록 버튼은 어느 쪽이든 false 를 명시해 발행으로 전환한다. + val isDraftToSend = if (asDraft) true.takeIf { route.isDraft } else false + SaveAfternoteCommand.Update(id = editingId, payload = updatePayload.copy(isDraft = isDraftToSend)) } else { val createInput = AfternoteEditorFormMapper.buildCreateInput( @@ -832,7 +848,7 @@ internal class AfternoteEditorViewModel memorialThumbnailUrl = memorialMedia.memorialVideo.displayed?.thumbnailUrl, memorialPhotoUrl = resolved.resolvedMemorialPhotoUrl, ) - SaveAfternoteCommand.Create(input = createInput) + SaveAfternoteCommand.Create(input = AfternoteEditorFormMapper.withDraft(createInput, asDraft)) } return Result.success(command) } @@ -855,6 +871,11 @@ internal class AfternoteEditorViewModel * 실패를 «빈 폼» 으로 흘려보내지 않는다 (#705) — 서버 수정(PATCH)은 보낸 값으로 기존 기록을 * 덮으므로, 못 읽은 상태의 빈 폼이 저장되면 기록이 소실된다. 그래서 실패는 [AfternoteEditorUiState.isPrefillFailed] * 로 남겨 화면이 오류·재시도를 그리고 [saveAfternote] 가 저장을 막게 한다. + * + * 서버 상세는 하나인데 응답 형태가 갈리므로(`AfternotedetailResponse` 의 Draft / Published*) + * 무엇으로 읽을지는 **여는 쪽**이 정한다 (#808) — 임시저장 목록에서 왔으면 이어쓰기 계약으로 + * 관용해서 읽고, 상세 화면에서 왔으면 발행 보장을 그대로 받는다. 프리필 이후는 두 경로가 같은 + * 폼을 채우고, 실패 처리도 위와 같이 공유한다. */ private fun loadExistingAfternoteForEdit(afternoteId: Long) { // 재시도가 진행 중인 조회를 자르고 들어온다 — 자르지 않으면 두 응답이 같은 폼을 두고 경합한다. @@ -862,10 +883,24 @@ internal class AfternoteEditorViewModel prefillJob = viewModelScope.launch { dispatch(AfternoteEditorReducerEvent.PrefillStarted) - afternoteRepository - .getDetail(id = afternoteId) - .onSuccess { detail -> - val prefill = AfternoteEditorFormMapper.buildEditorFormPrefill(detail) + val loaded = + if (route.isDraft) { + afternoteRepository + .getDraftDetail(id = afternoteId) + .map { detail -> + AfternoteEditorFormMapper.buildEditorFormPrefill(detail) to + AfternoteEditorFormMapper.buildUpdateBaseline(detail) + } + } else { + afternoteRepository + .getDetail(id = afternoteId) + .map { detail -> + AfternoteEditorFormMapper.buildEditorFormPrefill(detail) to + AfternoteEditorFormMapper.buildUpdateBaseline(detail) + } + } + loaded + .onSuccess { (prefill, baseline) -> // UI 레이어 파사드가 TextFieldState·SnapshotStateList 등 UI 상태를 갱신하도록 위임. // skeleton 종료는 UI 가 prefill 적용을 마친 뒤 [AfternoteEditorIntent.ConsumePrefill] 로 통보한다 // (uiState 갱신 시점에 prefill 도착했어도 UI 가 form·TextFieldState 에 반영하기 전이라 @@ -877,7 +912,7 @@ internal class AfternoteEditorViewModel dispatch( AfternoteEditorReducerEvent.PrefillLoaded( prefill = prefill, - baseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail), + baseline = baseline, preserveRestoredForm = restoredFromSeededSnapshot, ), ) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/model/AfternoteRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/model/AfternoteRoute.kt index 2f9583eb22..b3b372751a 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/model/AfternoteRoute.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/model/AfternoteRoute.kt @@ -18,6 +18,11 @@ sealed interface AfternoteRoute : NavKey { data class EditorFlowRoute( val itemId: Long? = null, val initialType: AfternoteType, + /** + * 이어쓰기로 들어왔는지 (#808). 서버 상세는 하나인데 응답 형태가 갈리므로 + * (`AfternotedetailResponse` 의 Draft / Published*) 무엇으로 읽을지를 여는 쪽이 정한다. + */ + val isDraft: Boolean = false, ) : AfternoteRoute /** [EditorFlowRoute]의 시작 화면. flow 인자는 부모 route가 소유한다. */ diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorDraftPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorDraftPrefillTest.kt new file mode 100644 index 0000000000..14eec27937 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorDraftPrefillTest.kt @@ -0,0 +1,162 @@ +package com.afternote.feature.afternote.presentation.editor + +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput +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.DraftContent +import com.afternote.feature.afternote.domain.model.author.DraftDetail +import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia +import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill +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 + +/** + * 임시저장 상세 → 에디터 프리필, 그리고 임시저장 여부가 저장 입력에 실리는 경계 (#808). + * + * 임시저장은 「미완성을 그대로 보존한 것」이라 종류별 값이 통째로 비어 올 수 있다 — 그 «없음» 이 + * 빈 입력칸이 되어야 이어쓰기가 성립한다. 발행 프리필의 계약은 [AfternoteEditorReceiverPrefillTest] 가 지킨다. + */ +class AfternoteEditorDraftPrefillTest { + @Test + fun `곡을 안 담은 임시저장은 빈 추억 노트 폼으로 열린다`() { + val prefill = AfternoteEditorFormMapper.buildEditorFormPrefill(draft(AfternoteType.MEMORIAL)) + + val content = prefill.content as EditorContentPrefill.Memorial + assertTrue(content.playlistSongs.isEmpty()) + assertEquals(null, content.photoUrl) + assertEquals(null, content.videoUrl) + } + + @Test + fun `계정 정보를 아직 안 쓴 임시저장은 빈 입력칸으로 열린다`() { + val prefill = AfternoteEditorFormMapper.buildEditorFormPrefill(draft(AfternoteType.SOCIAL_NETWORK)) + + val content = prefill.content as EditorContentPrefill.SocialNetwork + assertEquals("", content.credentials.id) + assertEquals("", content.credentials.password) + } + + @Test + fun `임시저장 상세의 수신자는 발행 상세와 같게 옮겨진다`() { + val prefill = + AfternoteEditorFormMapper.buildEditorFormPrefill( + draft(AfternoteType.GALLERY_AND_FILES, DetailReceiver(receiverId = 7L, name = "김수신", relation = "딸")), + ) + + assertEquals(listOf(7L), prefill.receivers.map { it.id }) + assertEquals("김수신", prefill.receivers.single().name) + } + + @Test + fun `임시저장 저장은 생성 입력에 isDraft 를 싣고 정식 등록은 안 싣는다`() { + val input = + CreateAfternoteInput.Gallery( + com.afternote.feature.afternote.domain.model.author.CreateGalleryPayload( + title = "t", + processingMethods = emptyList(), + ), + ) + + val draftInput = AfternoteEditorFormMapper.withDraft(input, isDraft = true) as CreateAfternoteInput.Gallery + val publishInput = AfternoteEditorFormMapper.withDraft(input, isDraft = false) as CreateAfternoteInput.Gallery + + assertTrue(draftInput.payload.isDraft) + assertEquals(false, publishInput.payload.isDraft) + } + + @Test + fun `종류별 미작성 값을 그대로 저장하면 수정 필드를 싣지 않는다`() { + listOf( + AfternoteType.SOCIAL_NETWORK, + AfternoteType.BUSINESS, + AfternoteType.GALLERY_AND_FILES, + AfternoteType.MEMORIAL, + ).forEach { type -> + val detail = draft(type) + val updated = + AfternoteEditorFormMapper.buildUpdatePayload( + type = type, + payload = RegisterAfternotePayload(serviceName = detail.serviceName, date = "2026-09-03"), + selectedReceiverIds = emptyList(), + playlistSongs = emptyList(), + memorialMediaUrls = MemorialMediaUrls(), + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail), + ) + + assertNull(updated.title) + assertNull(updated.processingMethods) + assertNull(updated.leaveMessageBlocks) + assertNull(updated.credentials) + assertNull(updated.receiverIds) + assertNull(updated.memorial) + } + } + + @Test + fun `계정 정보가 없던 임시저장에 비밀번호만 쓰면 비밀번호만 싣는다`() { + listOf(AfternoteType.SOCIAL_NETWORK, AfternoteType.BUSINESS).forEach { type -> + val detail = draft(type) + val updated = + AfternoteEditorFormMapper.buildUpdatePayload( + type = type, + payload = + RegisterAfternotePayload( + serviceName = detail.serviceName, + date = "2026-09-03", + password = "새 비밀번호", + ), + selectedReceiverIds = emptyList(), + playlistSongs = emptyList(), + memorialMediaUrls = MemorialMediaUrls(), + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail), + ) + + assertEquals("새 비밀번호", updated.credentials?.password) + assertNull(updated.credentials?.id) + assertNull(updated.title) + assertNull(updated.processingMethods) + assertNull(updated.receiverIds) + assertNull(updated.memorial) + } + } + + private fun draft( + type: AfternoteType, + vararg receivers: DetailReceiver, + ) = DraftDetail( + id = 1L, + serviceName = "제목만 쓴 노트", + timestamps = DetailTimestamps(updatedAt = "2026-09-03"), + receivers = receivers.toList(), + leaveMessageBlocks = emptyList(), + content = + when (type) { + AfternoteType.SOCIAL_NETWORK -> { + DraftContent.SocialNetwork(credentials = null, processingMethods = emptyList()) + } + + AfternoteType.BUSINESS -> { + DraftContent.Business(credentials = null, processingMethods = emptyList()) + } + + AfternoteType.GALLERY_AND_FILES -> { + DraftContent.Gallery(processingMethods = emptyList()) + } + + AfternoteType.MEMORIAL -> { + DraftContent.Memorial( + songs = emptyList(), + media = MemorialMedia(photoUrl = null, videoUrl = null, thumbnailUrl = null), + ) + } + + AfternoteType.ESTATE -> { + DraftContent.Estate + } + }, + ) +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt index 6ce78739e3..d24d22c607 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -7,6 +7,8 @@ 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.DraftContent +import com.afternote.feature.afternote.domain.model.author.DraftDetail 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 @@ -186,6 +188,89 @@ class AfternoteEditorProcessDeathPrefillTest { ) } + @Test + fun `임시저장 복원도 편집을 보존하고 변경한 필드만 다시 저장한다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDraftDetail = { Result.success(serverDraft()) } + onUpdate = { id, _ -> Result.success(id) } + } + val viewModel = viewModel(repository, isDraft = true) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + assertNull(viewModel.uiState.value.pendingPrefill) + assertFalse(viewModel.uiState.value.isPrefillLoading) + assertEquals(EDITED_SERVICE, viewModel.uiState.value.form.selectedService) + + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = + RegisterAfternotePayload( + serviceName = EDITED_SERVICE, + date = "2026-09-08", + processingMethods = listOf(SERVER_PROCESSING_METHOD), + ), + selectedReceiverIds = emptyList(), + memorialMedia = SaveAfternoteMemorialMedia(), + asDraft = true, + ), + ) + runCurrent() + + val updated = repository.updateCalls.single().second + assertEquals(EDITED_SERVICE, updated.title) + assertEquals(true, updated.isDraft) + assertNull("표시용 프리필이 아니라 서버 원본을 기준으로 비교해야 한다", updated.processingMethods) + assertNull(updated.receiverIds) + } + + @Test + fun `임시저장 발행 전환은 그대로인 필드를 다시 보내지 않는다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDraftDetail = { Result.success(serverDraft()) } + onUpdate = { id, _ -> Result.success(id) } + } + val viewModel = viewModel(repository, isDraft = true, prefillSeeded = false) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + viewModel.onIntent(AfternoteEditorIntent.ConsumePrefill) + + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = + RegisterAfternotePayload( + serviceName = SERVER_SERVICE, + date = "2026-09-08", + processingMethods = listOf(SERVER_PROCESSING_METHOD), + ), + selectedReceiverIds = emptyList(), + memorialMedia = SaveAfternoteMemorialMedia(), + asDraft = false, + ), + ) + runCurrent() + + val updated = repository.updateCalls.single().second + assertEquals(false, updated.isDraft) + assertNull(updated.title) + assertNull(updated.processingMethods) + assertNull(updated.receiverIds) + } + + private fun serverDraft() = + DraftDetail( + id = EDIT_ID, + serviceName = SERVER_SERVICE, + timestamps = DetailTimestamps(updatedAt = "2026-09-08"), + receivers = emptyList(), + leaveMessageBlocks = emptyList(), + content = DraftContent.Gallery(processingMethods = listOf(SERVER_PROCESSING_METHOD)), + ) + private fun serverDetail() = Detail( id = EDIT_ID, @@ -211,14 +296,16 @@ class AfternoteEditorProcessDeathPrefillTest { afternoteRepository: FakeAfternoteRepository, prefillSeeded: Boolean = true, snapshot: String = restoredSnapshot(), + isDraft: Boolean = false, ): AfternoteEditorViewModel = AfternoteEditorViewModel( - route = AfternoteRoute.EditorFlowRoute(itemId = EDIT_ID, initialType = AfternoteType.GALLERY_AND_FILES), + route = AfternoteRoute.EditorFlowRoute(itemId = EDIT_ID, initialType = AfternoteType.GALLERY_AND_FILES, isDraft = isDraft), savedStateHandle = SavedStateHandle( buildMap { put("initialType", AfternoteType.GALLERY_AND_FILES) put("itemId", EDIT_ID) + put("isDraft", isDraft) put("editor_form_snapshot_v5", snapshot) // 화면이 프리필을 폼에 실을 때 ViewModel 이 같은 번들에 남기는 표식. if (prefillSeeded) put("editor_prefill_seeded_item_id", EDIT_ID) 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 d2750916b6..39499f19f6 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 @@ -1,9 +1,12 @@ package com.afternote.feature.afternote.presentation.editor +import androidx.lifecycle.SavedStateHandle 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.DraftContent +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.playlist.DetailSong import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia @@ -35,7 +38,6 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -87,7 +89,7 @@ class AfternoteEditorServerMediaDeleteSaveTest { val memorial = requireNotNull(updatePayload.memorial) assertEquals(FieldPatch.Set(null), memorial.memorialPhotoUrl) assertEquals(FieldPatch.Set(null), memorial.memorialVideo) - assertNull(memorial.songs) // Unchanged songs remain on the server. + assertNull(memorial.songs) assertEquals(AFTERNOTE_ID, first.uiState.value.savedId) val updatedMedia = repository.details.getValue(AFTERNOTE_ID).memorialMedia() @@ -142,6 +144,54 @@ class AfternoteEditorServerMediaDeleteSaveTest { } } + @Test + fun `임시저장 유지와 발행 전환 모두 기존 미디어를 다시 보내거나 지우지 않는다`() = + runTest(dispatcher) { + for (asDraft in listOf(true, false)) { + val repository = FakeAfternoteRepository(initialDraftDetails = mapOf(AFTERNOTE_ID to serverMemorialDraft())) + val viewModel = viewModel(repository, isDraft = true) + collectState(viewModel) + applyLoadedPrefill(viewModel) + assertServerMediaAndSongs(viewModel.uiState.value.form) + + viewModel.saveCurrentMemorialForm(asDraft = asDraft) + advanceUntilIdle() + + val payload = repository.updateCalls.single().second + assertEquals(asDraft, payload.isDraft) + assertNull(payload.memorial) + } + } + + @Test + fun `발행분 편집의 임시저장은 isDraft 를 싣지 않아 발행분을 강등하지 않는다`() = + runTest(dispatcher) { + val repository = FakeAfternoteRepository(initialDetails = mapOf(AFTERNOTE_ID to serverMemorialDetail())) + val viewModel = viewModel(repository, isDraft = false) + collectState(viewModel) + applyLoadedPrefill(viewModel) + + viewModel.saveCurrentMemorialForm(asDraft = true) + advanceUntilIdle() + + val payload = repository.updateCalls.single().second + assertNull(payload.isDraft) + assertEquals(AFTERNOTE_ID, viewModel.uiState.value.savedId) + } + + private fun serverMemorialDraft(): DraftDetail { + val detail = serverMemorialDetail() + val content = detail.content as DetailContent.Memorial + return DraftDetail( + id = detail.id, + serviceName = detail.serviceName, + timestamps = detail.timestamps, + receivers = detail.receivers, + leaveMessageBlocks = detail.leaveMessageBlocks, + content = DraftContent.Memorial(songs = content.songs, media = content.media), + ) + } + private fun TestScope.collectState(viewModel: AfternoteEditorViewModel) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.uiState.collect {} @@ -156,13 +206,14 @@ class AfternoteEditorServerMediaDeleteSaveTest { advanceUntilIdle() } - private fun AfternoteEditorViewModel.saveCurrentMemorialForm() { + private fun AfternoteEditorViewModel.saveCurrentMemorialForm(asDraft: Boolean = false) { val form = uiState.value.form onIntent( AfternoteEditorIntent.Save( payload = validMemorialPayload(), selectedReceiverIds = emptyList(), memorialMedia = form.fullMemorialMediaForSave(), + asDraft = asDraft, ), ) } @@ -189,14 +240,14 @@ class AfternoteEditorServerMediaDeleteSaveTest { assertEquals(listOf("배경음악"), form.memorialPlaylistSongs.map { it.title }) } - private fun viewModel(repository: FakeAfternoteRepository): AfternoteEditorViewModel { - val savedStateHandle = - afternoteEditorSavedStateHandle( - initialType = AfternoteType.MEMORIAL, - itemId = AFTERNOTE_ID, - ) - return AfternoteEditorViewModel( - route = savedStateHandle.editorFlowRoute(), + private fun viewModel( + repository: FakeAfternoteRepository, + savedStateHandle: SavedStateHandle = + afternoteEditorSavedStateHandle(initialType = AfternoteType.MEMORIAL, itemId = AFTERNOTE_ID), + isDraft: Boolean = false, + ): AfternoteEditorViewModel = + AfternoteEditorViewModel( + route = savedStateHandle.editorFlowRoute().copy(isDraft = isDraft), savedStateHandle = savedStateHandle, userReceiverRepository = afternoteAuthorUserReceiverRepository(), afternoteRepository = repository, @@ -215,7 +266,6 @@ class AfternoteEditorServerMediaDeleteSaveTest { saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = NoopAuthorErrorReporter, ) - } private fun validMemorialPayload(): RegisterAfternotePayload = RegisterAfternotePayload( diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidatorTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidatorTest.kt index df5c82d2d6..2628cad815 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidatorTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidatorTest.kt @@ -1,5 +1,6 @@ package com.afternote.feature.afternote.presentation.editor +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 import com.afternote.feature.afternote.presentation.editor.state.AfternoteValidationError @@ -69,4 +70,53 @@ class AfternoteEditorValidatorTest { assertNull(error) } + + // ---- 임시저장(asDraft=true)이 «건너뛰는 것» 과 «그대로 막는 것» (#808) ---- + + @Test + fun `임시저장은 ESTATE 를 그대로 막는다 - 통과시키면 매퍼가 죽는다`() { + val error = + AfternoteEditorValidator.validate( + form = EditorFormState(typeForm = AfternoteTypeForm.Estate), + payload = payload, + asDraft = true, + ) + + assertEquals(AfternoteValidationError.UNIMPLEMENTED_TYPE, error) + } + + @Test + fun `임시저장은 제목을 그대로 요구한다 - 서버가 NotBlank 로 400 을 낸다`() { + val error = + AfternoteEditorValidator.validate( + form = EditorFormState(typeForm = AfternoteTypeForm.Gallery()), + payload = payload.copy(serviceName = " "), + asDraft = true, + ) + + assertEquals(AfternoteValidationError.TITLE_REQUIRED, error) + } + + @Test + fun `임시저장은 제목만 채운 남기실 말씀 블록을 그대로 막는다`() { + val error = + AfternoteEditorValidator.validate( + form = EditorFormState(typeForm = AfternoteTypeForm.Gallery()), + payload = payload.copy(messageBlocks = listOf(EditorMessageTextBlock(title = "제목", body = ""))), + asDraft = true, + ) + + assertEquals(AfternoteValidationError.LEAVE_MESSAGE_BODY_REQUIRED, error) + } + + @Test + fun `임시저장은 계정 정보만 건너뛴다 - 서버가 완화하는 축이다`() { + val form = EditorFormState(typeForm = AfternoteTypeForm.Social()) + + assertEquals( + AfternoteValidationError.ACCOUNT_CREDENTIALS_REQUIRED, + AfternoteEditorValidator.validate(form = form, payload = payload), + ) + assertNull(AfternoteEditorValidator.validate(form = form, payload = payload, asDraft = true)) + } }