diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorAndroidTest.kt index 8b5d711d0e..ab19796188 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorAndroidTest.kt @@ -28,11 +28,9 @@ import com.afternote.feature.afternote.domain.model.author.ProcessingMethod import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.domain.repository.author.AuthorReceiverRepository import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MediaKind +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.PhotoUploadOutcome -import com.afternote.feature.afternote.domain.repository.author.VideoUploadOutcome import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.presentation.author.editor.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.author.editor.SaveAfternoteMemorialMedia @@ -178,23 +176,24 @@ class AfternoteAuthorAndroidTest { memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { Result.success("https://cdn.test/thumb.jpg") }, resolveMemorialMediaForSave = ResolveMemorialMediaForSaveUseCase( - memorialVideoUploadRepository = - MemorialVideoUploadRepository { input -> + memorialMediaUploadRepository = + MemorialMediaUploadRepository { input, kind -> Result.success( when (input) { - MediaInput.None -> VideoUploadOutcome.Empty - is MediaInput.Local -> VideoUploadOutcome.FreshlyUploaded("https://cdn.test/video.mp4") - is MediaInput.Remote -> VideoUploadOutcome.Existing(input.url) - }, - ) - }, - memorialPhotoUploadRepository = - MemorialPhotoUploadRepository { input -> - Result.success( - when (input) { - MediaInput.None -> PhotoUploadOutcome.Empty - is MediaInput.Local -> PhotoUploadOutcome.FreshlyUploaded("https://cdn.test/photo.jpg") - is MediaInput.Remote -> PhotoUploadOutcome.Existing(input.url) + MediaInput.None -> { + null + } + + is MediaInput.Local -> { + when (kind) { + MediaKind.VIDEO -> "https://cdn.test/video.mp4" + MediaKind.PHOTO -> "https://cdn.test/photo.jpg" + } + } + + is MediaInput.Remote -> { + input.url + } }, ) }, diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt index 147f4dcbb9..4f7306b7a1 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt @@ -60,11 +60,9 @@ import com.afternote.feature.afternote.domain.model.author.ReceiverRefPayload import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.domain.repository.author.AuthorReceiverRepository import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MediaKind +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.PhotoUploadOutcome -import com.afternote.feature.afternote.domain.repository.author.VideoUploadOutcome import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.author.detail.AfternoteDetailDeleteResult @@ -559,38 +557,23 @@ private fun editorViewModel( }, resolveMemorialMediaForSave = ResolveMemorialMediaForSaveUseCase( - memorialVideoUploadRepository = - MemorialVideoUploadRepository { input -> + memorialMediaUploadRepository = + MemorialMediaUploadRepository { input, kind -> Result.success( when (input) { MediaInput.None -> { - VideoUploadOutcome.Empty + null } is MediaInput.Local -> { - VideoUploadOutcome.FreshlyUploaded("https://cdn.test/video.mp4") + when (kind) { + MediaKind.VIDEO -> "https://cdn.test/video.mp4" + MediaKind.PHOTO -> "https://cdn.test/photo.jpg" + } } is MediaInput.Remote -> { - VideoUploadOutcome.Existing(input.url) - } - }, - ) - }, - memorialPhotoUploadRepository = - MemorialPhotoUploadRepository { input -> - Result.success( - when (input) { - MediaInput.None -> { - PhotoUploadOutcome.Empty - } - - is MediaInput.Local -> { - PhotoUploadOutcome.FreshlyUploaded("https://cdn.test/photo.jpg") - } - - is MediaInput.Remote -> { - PhotoUploadOutcome.Existing(input.url) + input.url } }, ) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorImplementedCoverageAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorImplementedCoverageAndroidTest.kt index 4e4e17d051..b700a38714 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorImplementedCoverageAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorImplementedCoverageAndroidTest.kt @@ -30,11 +30,9 @@ import com.afternote.feature.afternote.domain.model.author.ProcessingMethod import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.domain.repository.author.AuthorReceiverRepository import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MediaKind +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.PhotoUploadOutcome -import com.afternote.feature.afternote.domain.repository.author.VideoUploadOutcome import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.presentation.author.editor.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.author.editor.SaveAfternoteMemorialMedia @@ -344,40 +342,27 @@ private fun implementedCoverageViewModel( }, resolveMemorialMediaForSave = ResolveMemorialMediaForSaveUseCase( - memorialVideoUploadRepository = - MemorialVideoUploadRepository { input -> - videoInputs += input + memorialMediaUploadRepository = + MemorialMediaUploadRepository { input, kind -> + when (kind) { + MediaKind.VIDEO -> videoInputs += input + MediaKind.PHOTO -> photoInputs += input + } Result.success( when (input) { MediaInput.None -> { - VideoUploadOutcome.Empty + null } is MediaInput.Local -> { - VideoUploadOutcome.FreshlyUploaded("https://cdn.test/uploaded-video.mp4") + when (kind) { + MediaKind.VIDEO -> "https://cdn.test/uploaded-video.mp4" + MediaKind.PHOTO -> "https://cdn.test/uploaded-photo.jpg" + } } is MediaInput.Remote -> { - VideoUploadOutcome.Existing(input.url) - } - }, - ) - }, - memorialPhotoUploadRepository = - MemorialPhotoUploadRepository { input -> - photoInputs += input - Result.success( - when (input) { - MediaInput.None -> { - PhotoUploadOutcome.Empty - } - - is MediaInput.Local -> { - PhotoUploadOutcome.FreshlyUploaded("https://cdn.test/uploaded-photo.jpg") - } - - is MediaInput.Remote -> { - PhotoUploadOutcome.Existing(input.url) + input.url } }, ) diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteAuthorRepositoryModule.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteAuthorRepositoryModule.kt index 4a6a71755a..a64b3d80dc 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteAuthorRepositoryModule.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/di/AfternoteAuthorRepositoryModule.kt @@ -2,15 +2,13 @@ package com.afternote.feature.afternote.data.di import com.afternote.feature.afternote.data.repositoryimpl.author.AfternoteRepositoryImpl import com.afternote.feature.afternote.data.repositoryimpl.author.AuthorReceiverRepositoryImpl -import com.afternote.feature.afternote.data.repositoryimpl.author.MemorialPhotoUploadRepositoryImpl +import com.afternote.feature.afternote.data.repositoryimpl.author.MemorialMediaUploadRepositoryImpl import com.afternote.feature.afternote.data.repositoryimpl.author.MemorialThumbnailUploadRepositoryImpl -import com.afternote.feature.afternote.data.repositoryimpl.author.MemorialVideoUploadRepositoryImpl import com.afternote.feature.afternote.data.repositoryimpl.author.MusicSearchRepositoryImpl import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.domain.repository.author.AuthorReceiverRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository import com.afternote.feature.afternote.domain.repository.author.MusicSearchRepository import dagger.Binds import dagger.Module @@ -44,10 +42,5 @@ interface AfternoteAuthorRepositoryModule { @Suppress("unused") @Binds @Singleton - fun bindMemorialVideoUploadRepository(impl: MemorialVideoUploadRepositoryImpl): MemorialVideoUploadRepository - - @Suppress("unused") - @Binds - @Singleton - fun bindMemorialPhotoUploadRepository(impl: MemorialPhotoUploadRepositoryImpl): MemorialPhotoUploadRepository + fun bindMemorialMediaUploadRepository(impl: MemorialMediaUploadRepositoryImpl): MemorialMediaUploadRepository } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialMediaUploadRepositoryImpl.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialMediaUploadRepositoryImpl.kt new file mode 100644 index 0000000000..85ac597210 --- /dev/null +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialMediaUploadRepositoryImpl.kt @@ -0,0 +1,47 @@ +package com.afternote.feature.afternote.data.repositoryimpl.author + +import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.core.domain.repository.VideoUploadRepository +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 +import javax.inject.Inject + +/** + * 서버 S3 의 애프터노트 미디어 폴더명. presigned URL 경로에 박혀 `bucket/afternotes/` 형태가 됨. 서버와 약속된 문자열. + * + * 같은 패키지의 [MemorialThumbnailUploadRepositoryImpl] 가 이 값을 공유하므로 `internal`. + */ +internal const val DIRECTORY_AFTERNOTES = "afternotes" + +/** + * 추모 미디어 *상태 해석* + 필요 시 업로드. 업로드 자체(presigned 발급·S3 PUT·MIME 추론)는 + * [PhotoUploadRepository] · [VideoUploadRepository] 가 담당하고, 본 클래스는 분기 라우팅만 한다. + */ +class MemorialMediaUploadRepositoryImpl + @Inject + constructor( + private val photoUploadRepository: PhotoUploadRepository, + private val videoUploadRepository: VideoUploadRepository, + ) : MemorialMediaUploadRepository { + override suspend fun resolve( + input: MediaInput, + kind: MediaKind, + ): Result = + when (input) { + MediaInput.None -> { + Result.success(null) + } + + is MediaInput.Remote -> { + Result.success(input.url) + } + + is MediaInput.Local -> { + when (kind) { + MediaKind.PHOTO -> photoUploadRepository.upload(input.uri, DIRECTORY_AFTERNOTES) + MediaKind.VIDEO -> videoUploadRepository.upload(input.uri, DIRECTORY_AFTERNOTES) + } + } + } + } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialPhotoUploadRepositoryImpl.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialPhotoUploadRepositoryImpl.kt deleted file mode 100644 index 37b72eb3f3..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialPhotoUploadRepositoryImpl.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.afternote.feature.afternote.data.repositoryimpl.author - -import com.afternote.core.domain.repository.PhotoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.PhotoUploadOutcome -import javax.inject.Inject - -/** - * 서버 S3 의 애프터노트 미디어 폴더명. presigned URL 경로에 박혀 `bucket/afternotes/` 형태가 됨. 서버와 약속된 문자열. - * - * 같은 패키지의 [MemorialVideoUploadRepositoryImpl] · [MemorialThumbnailUploadRepositoryImpl] 가 이 값을 공유하므로 `internal`. - */ -internal const val DIRECTORY_AFTERNOTES = "afternotes" - -/** - * 영정 사진 *상태 해석* + 필요 시 업로드. 입력 [MediaInput] 의 sealed 분기: - * - [MediaInput.Local] → [PhotoUploadRepository] 업로드 후 [PhotoUploadOutcome.FreshlyUploaded] - * - [MediaInput.Remote] → 입력 그대로 [PhotoUploadOutcome.Existing] - * - [MediaInput.None] → [PhotoUploadOutcome.Empty] - * - * 픽 우선순위·로컬/원격 판별은 호출부가 [MediaInput] 을 구성할 때 끝낸다. - */ -class MemorialPhotoUploadRepositoryImpl - @Inject - constructor( - private val photoUploadRepository: PhotoUploadRepository, - ) : MemorialPhotoUploadRepository { - override suspend fun resolvePhoto(input: MediaInput): Result = - when (input) { - MediaInput.None -> { - Result.success(PhotoUploadOutcome.Empty) - } - - is MediaInput.Remote -> { - Result.success(PhotoUploadOutcome.Existing(input.url)) - } - - is MediaInput.Local -> { - photoUploadRepository - .upload(input.uri, DIRECTORY_AFTERNOTES) - .map { PhotoUploadOutcome.FreshlyUploaded(it) } - } - } - } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialThumbnailUploadRepositoryImpl.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialThumbnailUploadRepositoryImpl.kt index a3c6c4bab2..f0c6276595 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialThumbnailUploadRepositoryImpl.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialThumbnailUploadRepositoryImpl.kt @@ -15,7 +15,7 @@ import okhttp3.RequestBody.Companion.toRequestBody import javax.inject.Inject import javax.inject.Named -// DIRECTORY_AFTERNOTES 는 같은 패키지 [MemorialPhotoUploadRepositoryImpl] 의 internal const 공유. +// DIRECTORY_AFTERNOTES 는 같은 패키지 [MemorialMediaUploadRepositoryImpl] 의 internal const 공유. private const val EXTENSION_JPG = "jpg" private const val CONTENT_TYPE_JPEG = "image/jpeg" diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialVideoUploadRepositoryImpl.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialVideoUploadRepositoryImpl.kt deleted file mode 100644 index cfee51a238..0000000000 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/MemorialVideoUploadRepositoryImpl.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.afternote.feature.afternote.data.repositoryimpl.author - -import com.afternote.core.domain.repository.VideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.VideoUploadOutcome -import javax.inject.Inject - -// DIRECTORY_AFTERNOTES 는 같은 패키지 [MemorialPhotoUploadRepositoryImpl] 의 internal const 공유. - -/** - * 추모 영상 *상태 해석* + 필요 시 업로드. 입력 [MediaInput] 의 sealed 분기로 반환: - * - [MediaInput.Local] → [VideoUploadRepository] 위임 후 [VideoUploadOutcome.FreshlyUploaded] - * - [MediaInput.Remote] → 입력 그대로 [VideoUploadOutcome.Existing] - * - [MediaInput.None] → [VideoUploadOutcome.Empty] - * - * 로컬/원격 판별은 호출부에서 [MediaInput] 으로 끝나 있다. S3 PUT·MIME 추론·temp 파일 관리는 - * [VideoUploadRepository] 가 담당. 본 클래스는 분기 라우팅만. - */ -class MemorialVideoUploadRepositoryImpl - @Inject - constructor( - private val videoUploadRepository: VideoUploadRepository, - ) : MemorialVideoUploadRepository { - override suspend fun resolveVideo(input: MediaInput): Result = - when (input) { - MediaInput.None -> { - Result.success(VideoUploadOutcome.Empty) - } - - is MediaInput.Remote -> { - Result.success(VideoUploadOutcome.Existing(input.url)) - } - - is MediaInput.Local -> { - videoUploadRepository - .upload(input.uri, DIRECTORY_AFTERNOTES) - .map { VideoUploadOutcome.FreshlyUploaded(it) } - } - } - } diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaInput.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaInput.kt index bd2565b425..66fa12acca 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaInput.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaInput.kt @@ -5,7 +5,7 @@ package com.afternote.feature.afternote.domain.repository.author * * 진입 시점(presentation)에 로컬/원격을 *한 번* 확정해 resolve 경계로 넘긴다. 그러면 data 레이어가 * `content://` 같은 prefix 를 런타임에 문자열 비교하지 않고 [when] 분기(exhaustive)로 처리할 수 있다. - * 새 상태가 늘면 분기 누락이 컴파일 에러로 드러난다. (출력단 sealed `*UploadOutcome` #247 의 입력단 대칭.) + * 새 상태가 늘면 분기 누락이 컴파일 에러로 드러난다. */ sealed interface MediaInput { /** 사용자가 이번 세션에 새로 고른 로컬 파일(`content://` URI). 업로드 대상. */ diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaKind.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaKind.kt new file mode 100644 index 0000000000..b59d876a48 --- /dev/null +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MediaKind.kt @@ -0,0 +1,7 @@ +package com.afternote.feature.afternote.domain.repository.author + +/** 추모 미디어의 종류. 업로드 위임 대상을 가른다. */ +enum class MediaKind { + PHOTO, + VIDEO, +} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialMediaUploadRepository.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialMediaUploadRepository.kt new file mode 100644 index 0000000000..520eaf4a16 --- /dev/null +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialMediaUploadRepository.kt @@ -0,0 +1,17 @@ +package com.afternote.feature.afternote.domain.repository.author + +/** + * 추모 미디어(영정 사진·추모 영상)의 *상태 해석* + 필요 시 업로드. + * + * 로컬/원격 판별은 호출부가 [MediaInput] 을 구성할 때 끝내고, 여기서는 [when] 분기로만 처리한다. + * + * - [MediaInput.Local] → [kind] 에 맞는 업로드 후 그 결과 URL + * - [MediaInput.Remote] → 입력 URL 그대로 + * - [MediaInput.None] → null + */ +fun interface MemorialMediaUploadRepository { + suspend fun resolve( + input: MediaInput, + kind: MediaKind, + ): Result +} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialPhotoUploadRepository.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialPhotoUploadRepository.kt deleted file mode 100644 index ed0c3774d2..0000000000 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialPhotoUploadRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.afternote.feature.afternote.domain.repository.author - -/** - * 영정 사진의 *서버 자원 상태 해석* + 필요 시 업로드. - * - * 새로 고른 로컬 사진과 기존 원격 사진의 우선순위(픽 우선)는 *호출부* 가 [MediaInput] 을 구성할 때 정한다. - * Repository 는 확정된 [MediaInput] 을 [when] 분기로 처리한다. - * - * - [MediaInput.Local] → 업로드 → [PhotoUploadOutcome.FreshlyUploaded] - * - [MediaInput.Remote] → 입력 그대로 [PhotoUploadOutcome.Existing] - * - [MediaInput.None] → [PhotoUploadOutcome.Empty] - */ -fun interface MemorialPhotoUploadRepository { - suspend fun resolvePhoto(input: MediaInput): Result -} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialVideoUploadRepository.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialVideoUploadRepository.kt deleted file mode 100644 index 551e97e967..0000000000 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/MemorialVideoUploadRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.afternote.feature.afternote.domain.repository.author - -/** - * 추모(플레이리스트) 영상의 *서버 자원 상태 해석* + 필요 시 업로드. - * - * 입력 [MediaInput] 으로 로컬/원격이 이미 확정돼 들어오므로, Repository 는 `content://` prefix 를 - * 판별하지 않고 [when] 분기로 [VideoUploadOutcome] 를 만든다. - * - * - [MediaInput.Local] → S3 presigned upload → [VideoUploadOutcome.FreshlyUploaded] - * - [MediaInput.Remote] → 입력 그대로 [VideoUploadOutcome.Existing] - * - [MediaInput.None] → [VideoUploadOutcome.Empty] - */ -fun interface MemorialVideoUploadRepository { - suspend fun resolveVideo(input: MediaInput): Result -} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/PhotoUploadOutcome.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/PhotoUploadOutcome.kt deleted file mode 100644 index 2854cab0ce..0000000000 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/PhotoUploadOutcome.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.afternote.feature.afternote.domain.repository.author - -/** - * 영정 사진의 *서버 자원 상태* 를 표현하는 sealed 타입. - * - * 영상과 달리 사진은 *기존 영구 URL* 과 *새로 고른 로컬 URI* 가 별도 인자로 들어오며, Repository 가 - * 둘을 함께 보고 결과 분기를 결정한다. - * - * - [Empty] — 사진 없음 - * - [Existing] — 기존 영구 URL 그대로 (사용자가 새로 안 고름) - * - [FreshlyUploaded] — 사용자가 새로 고른 로컬 URI 를 업로드한 결과 - */ -sealed interface PhotoUploadOutcome { - data object Empty : PhotoUploadOutcome - - data class Existing( - val url: String, - ) : PhotoUploadOutcome - - data class FreshlyUploaded( - val url: String, - ) : PhotoUploadOutcome -} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/VideoUploadOutcome.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/VideoUploadOutcome.kt deleted file mode 100644 index 1148ef0cf9..0000000000 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/VideoUploadOutcome.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.afternote.feature.afternote.domain.repository.author - -/** - * 추모 영상의 *서버 자원 상태* 를 표현하는 sealed 타입. - * - * Repository 가 입력 String 을 보고 *형식을 직접 판별* 한 뒤 그 결과를 sealed 분기로 도메인에 전달한다. - * 도메인은 `"content://"`, `"X-Amz-"` 같은 인프라 디테일 문자열을 모르고 `when` 분기로만 처리. - * - * - [Empty] — 영상 없음 - * - [Existing] — 이미 서버가 알고 있는 자원 (입력 그대로 통과된 원격 URL). PATCH 시 페이로드에서 제거해 - * *변경 없음* 신호. - * - [FreshlyUploaded] — 방금 업로드해 새로 등록된 자원. POST/PATCH 페이로드에 그대로 보내야 서버가 - * 새 자원으로 등록. - */ -sealed interface VideoUploadOutcome { - data object Empty : VideoUploadOutcome - - data class Existing( - val url: String, - ) : VideoUploadOutcome - - data class FreshlyUploaded( - val url: String, - ) : VideoUploadOutcome -} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCase.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCase.kt index f320f58511..2f1d6edfde 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCase.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCase.kt @@ -1,10 +1,8 @@ package com.afternote.feature.afternote.domain.usecase.editor import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.PhotoUploadOutcome -import com.afternote.feature.afternote.domain.repository.author.VideoUploadOutcome +import com.afternote.feature.afternote.domain.repository.author.MediaKind +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import javax.inject.Inject class MemorialVideoSaveException( @@ -18,24 +16,14 @@ class MemorialPhotoSaveException( /** * 추모 영상·영정 사진의 *서버 저장 직전 정리* 도메인 로직. * - * 호출부가 로컬/원격을 확정한 [MediaInput] 을 받아 Repository 가 sealed [VideoUploadOutcome]/[PhotoUploadOutcome] - * 로 결과를 주고, 본 UseCase 는 그 sealed 분기를 저장 페이로드 규칙에 매핑한다. - * 도메인 본문이 `"content://"`, `"X-Amz-"` 같은 인프라 형식 디테일 문자열을 직접 비교하지 않는다. - * - * **POST/PATCH 동일 규칙** — 백엔드가 `S3Service.resolvePublicUrl(key)` 로 영구 public URL 을 발급하므로 - * 클라이언트가 GET 응답에서 받은 URL 을 그대로 PATCH 페이로드에 다시 보낼 수 있다. 따라서 PATCH 전용 분기 - * (`isUpdate`, presigned 마커 검사, `urlForUpdate`) 불필요 — #258 BE 확정 결과 반영. - * - * 각 Outcome 의 처리: - * - [VideoUploadOutcome.Empty] / [PhotoUploadOutcome.Empty] → null (미첨부) - * - [VideoUploadOutcome.Existing] / [PhotoUploadOutcome.Existing] → URL 그대로 전송 (영구 public URL) - * - [VideoUploadOutcome.FreshlyUploaded] / [PhotoUploadOutcome.FreshlyUploaded] → 업로드 결과 URL 전송 + * 호출부가 로컬/원격을 확정한 [MediaInput] 을 Repository 가 저장 페이로드에 실을 URL 로 해석해 주고, + * 본 UseCase 는 그 실패를 매체별 도메인 예외로 wrap 한다. 도메인 본문이 `"content://"` 같은 인프라 + * 형식 디테일 문자열을 직접 비교하지 않는다. */ class ResolveMemorialMediaForSaveUseCase @Inject constructor( - private val memorialVideoUploadRepository: MemorialVideoUploadRepository, - private val memorialPhotoUploadRepository: MemorialPhotoUploadRepository, + private val memorialMediaUploadRepository: MemorialMediaUploadRepository, ) { /** * @param video 영상 입력 — 호출부가 로컬/원격/없음을 확정한 [MediaInput]. @@ -45,37 +33,23 @@ class ResolveMemorialMediaForSaveUseCase video: MediaInput, photo: MediaInput, ): Result { - // 두 Repository 중 하나라도 실패 → 도메인 예외로 wrap 후 invoke 자체를 즉시 종료 + // 두 resolve 중 하나라도 실패 → 도메인 예외로 wrap 후 invoke 자체를 즉시 종료 // (getOrElse 람다 안의 `return` 은 non-local return — 함수 전체에서 빠져나감) - val videoOutcome = - memorialVideoUploadRepository - .resolveVideo(video) + val resolvedVideoUrl = + memorialMediaUploadRepository + .resolve(video, MediaKind.VIDEO) .getOrElse { return Result.failure(MemorialVideoSaveException(it)) } - val photoOutcome = - memorialPhotoUploadRepository - .resolvePhoto(photo) + val resolvedPhotoUrl = + memorialMediaUploadRepository + .resolve(photo, MediaKind.PHOTO) .getOrElse { return Result.failure(MemorialPhotoSaveException(it)) } return Result.success( ResolvedMemorialMediaForSave( - resolvedVideoUrl = videoOutcome.urlOrNull(), - resolvedMemorialPhotoUrl = photoOutcome.urlOrNull(), + resolvedVideoUrl = resolvedVideoUrl, + resolvedMemorialPhotoUrl = resolvedPhotoUrl, ), ) } - - private fun VideoUploadOutcome.urlOrNull(): String? = - when (this) { - VideoUploadOutcome.Empty -> null - is VideoUploadOutcome.Existing -> url - is VideoUploadOutcome.FreshlyUploaded -> url - } - - private fun PhotoUploadOutcome.urlOrNull(): String? = - when (this) { - PhotoUploadOutcome.Empty -> null - is PhotoUploadOutcome.Existing -> url - is PhotoUploadOutcome.FreshlyUploaded -> url - } } diff --git a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt index 306a07cf93..c2fddbb7bc 100644 --- a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt +++ b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt @@ -1,10 +1,8 @@ package com.afternote.feature.afternote.domain.usecase.editor import com.afternote.feature.afternote.domain.repository.author.MediaInput -import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository -import com.afternote.feature.afternote.domain.repository.author.PhotoUploadOutcome -import com.afternote.feature.afternote.domain.repository.author.VideoUploadOutcome +import com.afternote.feature.afternote.domain.repository.author.MediaKind +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -16,10 +14,9 @@ import org.junit.Test * [ResolveMemorialMediaForSaveUseCase] 비즈니스 로직 회귀 가드. * * 검증 핵심: - * 1. 입력 [MediaInput] 을 각 Repository 에 그대로 전달하는지 (로컬/원격 확정은 호출부 책임) - * 2. sealed [VideoUploadOutcome]/[PhotoUploadOutcome] 분기를 저장 페이로드 URL 로 매핑하는지 - * (Empty→null, Existing→url, FreshlyUploaded→url) - * 3. 영상 resolve 실패면 [MemorialVideoSaveException] 으로 wrap 하고 **사진 Repository 는 호출하지 않은 채** + * 1. 입력 [MediaInput] 과 [MediaKind] 를 Repository 에 그대로 전달하는지 (로컬/원격 확정은 호출부 책임) + * 2. 매체별 resolve 결과를 서로 뒤바꾸지 않고 각 필드에 담는지 (null 은 미첨부) + * 3. 영상 resolve 실패면 [MemorialVideoSaveException] 으로 wrap 하고 **사진은 resolve 하지 않은 채** * short-circuit 하는지 (non-local return) * 4. 사진 resolve 실패면 [MemorialPhotoSaveException] 으로 wrap 하고, 두 경우 모두 원본 예외를 cause 로 보존하는지 * @@ -27,13 +24,16 @@ import org.junit.Test */ class ResolveMemorialMediaForSaveUseCaseTest { @Test - fun `영상-사진 모두 성공 - FreshlyUploaded 영상과 Existing 사진을 각 URL 로 매핑`() { - val videoRepo = FakeVideoUploadRepository().apply { result = Result.success(VideoUploadOutcome.FreshlyUploaded("v-url")) } - val photoRepo = FakePhotoUploadRepository().apply { result = Result.success(PhotoUploadOutcome.Existing("p-url")) } + fun `영상-사진 모두 성공 - 각 결과 URL 을 제 필드에 매핑`() { + val repo = + FakeMemorialMediaUploadRepository().apply { + videoResult = Result.success("v-url") + photoResult = Result.success("p-url") + } val result = runBlocking { - ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)( + ResolveMemorialMediaForSaveUseCase(repo)( video = MediaInput.Local("content://video"), photo = MediaInput.Remote("https://cdn/photo.jpg"), ) @@ -45,13 +45,12 @@ class ResolveMemorialMediaForSaveUseCaseTest { } @Test - fun `Empty Outcome 은 null 로 매핑 - 미첨부`() { - val videoRepo = FakeVideoUploadRepository().apply { result = Result.success(VideoUploadOutcome.Empty) } - val photoRepo = FakePhotoUploadRepository().apply { result = Result.success(PhotoUploadOutcome.Empty) } + fun `null 결과는 null 로 매핑 - 미첨부`() { + val repo = FakeMemorialMediaUploadRepository() val resolved = runBlocking { - ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)(MediaInput.None, MediaInput.None) + ResolveMemorialMediaForSaveUseCase(repo)(MediaInput.None, MediaInput.None) }.getOrThrow() assertNull(resolved.resolvedVideoUrl) @@ -59,13 +58,16 @@ class ResolveMemorialMediaForSaveUseCaseTest { } @Test - fun `Existing 영상과 FreshlyUploaded 사진도 각 URL 로 매핑`() { - val videoRepo = FakeVideoUploadRepository().apply { result = Result.success(VideoUploadOutcome.Existing("existing-v")) } - val photoRepo = FakePhotoUploadRepository().apply { result = Result.success(PhotoUploadOutcome.FreshlyUploaded("fresh-p")) } + fun `원격 영상과 로컬 사진 조합도 각 URL 로 매핑`() { + val repo = + FakeMemorialMediaUploadRepository().apply { + videoResult = Result.success("existing-v") + photoResult = Result.success("fresh-p") + } val resolved = runBlocking { - ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)( + ResolveMemorialMediaForSaveUseCase(repo)( video = MediaInput.Remote("https://cdn/existing-v.mp4"), photo = MediaInput.Local("content://fresh-photo"), ) @@ -76,46 +78,43 @@ class ResolveMemorialMediaForSaveUseCaseTest { } @Test - fun `입력 MediaInput 을 각 Repository 에 그대로 전달`() { - val videoRepo = FakeVideoUploadRepository() - val photoRepo = FakePhotoUploadRepository() + fun `입력 MediaInput 과 MediaKind 를 Repository 에 그대로 전달`() { + val repo = FakeMemorialMediaUploadRepository() val videoInput = MediaInput.Local("content://fv") val photoInput = MediaInput.Remote("https://cdn/mp.jpg") runBlocking { - ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)(videoInput, photoInput) + ResolveMemorialMediaForSaveUseCase(repo)(videoInput, photoInput) } - assertEquals(videoInput, videoRepo.resolveVideoArg) - assertEquals(photoInput, photoRepo.resolvePhotoArg) + assertEquals(listOf(videoInput to MediaKind.VIDEO, photoInput to MediaKind.PHOTO), repo.calls) } @Test - fun `영상 resolve 실패면 MemorialVideoSaveException 으로 wrap 하고 사진 Repository 는 호출하지 않음`() { + fun `영상 resolve 실패면 MemorialVideoSaveException 으로 wrap 하고 사진은 resolve 하지 않음`() { val videoError = IllegalStateException("video resolve failed") - val videoRepo = FakeVideoUploadRepository().apply { result = Result.failure(videoError) } - val photoRepo = FakePhotoUploadRepository() + val repo = FakeMemorialMediaUploadRepository().apply { videoResult = Result.failure(videoError) } val result = runBlocking { - ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)(MediaInput.Local("content://v"), MediaInput.None) + ResolveMemorialMediaForSaveUseCase(repo)(MediaInput.Local("content://v"), MediaInput.None) } assertTrue(result.isFailure) assertTrue(result.exceptionOrNull() is MemorialVideoSaveException) assertSame(videoError, result.exceptionOrNull()?.cause) // 원본 예외를 cause 로 보존 - assertEquals(0, photoRepo.callCount) // short-circuit — 사진 Repository 미호출 + // short-circuit — 사진 resolve 미호출 + assertEquals(0, repo.calls.count { it.second == MediaKind.PHOTO }) } @Test fun `사진 resolve 실패면 MemorialPhotoSaveException 으로 wrap`() { val photoError = IllegalStateException("photo resolve failed") - val videoRepo = FakeVideoUploadRepository().apply { result = Result.success(VideoUploadOutcome.Empty) } - val photoRepo = FakePhotoUploadRepository().apply { result = Result.failure(photoError) } + val repo = FakeMemorialMediaUploadRepository().apply { photoResult = Result.failure(photoError) } val result = runBlocking { - ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)(MediaInput.None, MediaInput.Remote("https://cdn/p.jpg")) + ResolveMemorialMediaForSaveUseCase(repo)(MediaInput.None, MediaInput.Remote("https://cdn/p.jpg")) } assertTrue(result.isFailure) @@ -123,27 +122,20 @@ class ResolveMemorialMediaForSaveUseCaseTest { assertSame(photoError, result.exceptionOrNull()?.cause) } - private class FakeVideoUploadRepository : MemorialVideoUploadRepository { - var resolveVideoArg: MediaInput? = null - var callCount = 0 - var result: Result = Result.success(VideoUploadOutcome.Empty) - - override suspend fun resolveVideo(input: MediaInput): Result { - callCount++ - resolveVideoArg = input - return result - } - } - - private class FakePhotoUploadRepository : MemorialPhotoUploadRepository { - var resolvePhotoArg: MediaInput? = null - var callCount = 0 - var result: Result = Result.success(PhotoUploadOutcome.Empty) - - override suspend fun resolvePhoto(input: MediaInput): Result { - callCount++ - resolvePhotoArg = input - return result + private class FakeMemorialMediaUploadRepository : MemorialMediaUploadRepository { + val calls = mutableListOf>() + var videoResult: Result = Result.success(null) + var photoResult: Result = Result.success(null) + + override suspend fun resolve( + input: MediaInput, + kind: MediaKind, + ): Result { + calls += input to kind + return when (kind) { + MediaKind.VIDEO -> videoResult + MediaKind.PHOTO -> photoResult + } } } }