diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt index 66e0d2e7e7..13bd9f4bd7 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt @@ -125,7 +125,11 @@ class MindRecordFlowAndroidTest { DailyQuestionWriteViewModel( savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, - photoUploadRepository = FakePhotoUploadRepository("https://cdn.test/question.jpg"), + photoUploadRepository = + FakePhotoUploadRepository( + uploadedUrl = "https://cdn.test/question.jpg", + uploadedKey = "mindrecords/1/question.jpg", + ), draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), ) composeRule.setContent { AfternoteTheme {} } @@ -164,7 +168,11 @@ class MindRecordFlowAndroidTest { ), ), repository = repository, - photoUploadRepository = FakePhotoUploadRepository("https://cdn.test/image.jpg"), + photoUploadRepository = + FakePhotoUploadRepository( + uploadedUrl = "https://cdn.test/image.jpg", + uploadedKey = "mindrecords/1/image.jpg", + ), userRepository = appTestUserRepository(), draftLoader = MindRecordDraftLoader(repository, FakeDailyQuestionRepository()), ) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterFlowAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterFlowAndroidTest.kt index 14da16f74f..c461c9b6cc 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterFlowAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterFlowAndroidTest.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.afternote_fe.test.appTestUserRepository +import com.afternote.core.domain.model.UploadedFile import com.afternote.core.domain.testing.FakePhotoUploadRepository import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.timeletter.domain.model.NewTimeLetterBlock @@ -121,7 +122,15 @@ class TimeLetterFlowAndroidTest { val resolver = ResolveTimeLetterBlocksUseCase( FakePhotoUploadRepository( - onUpload = { uri, _ -> Result.success("https://cdn.test/${uri.substringAfterLast('/')}") }, + onUpload = { uri, _ -> + val name = uri.substringAfterLast('/') + Result.success( + UploadedFile( + fileUrl = "https://cdn.test/$name", + fileKey = "timeletters/1/$name", + ), + ) + }, ), ) return TimeLetterWriteViewModel( diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/PhotoUploadRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/PhotoUploadRepositoryImpl.kt index ed643ef980..68e28a56bf 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/PhotoUploadRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/PhotoUploadRepositoryImpl.kt @@ -5,6 +5,7 @@ import android.webkit.MimeTypeMap import androidx.core.net.toUri import com.afternote.core.common.di.IoDispatcher import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.model.UploadedFile import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.core.network.dto.PresignedUrlRequestDto import com.afternote.core.network.model.requireData @@ -73,7 +74,7 @@ internal class PhotoUploadRepositoryImpl override suspend fun upload( uriString: String, directory: String, - ): Result = + ): Result = runCatchingCancellable { val uri = uriString.toUri() val mime = context.contentResolver.getType(uri) @@ -127,7 +128,7 @@ internal class PhotoUploadRepositoryImpl } } - presigned.fileUrl + UploadedFile(fileUrl = presigned.fileUrl, fileKey = presigned.fileKey) } finally { tempFile.delete() } diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/VideoUploadRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/VideoUploadRepositoryImpl.kt index 94a0f32c4a..d02b7bbf34 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/VideoUploadRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/VideoUploadRepositoryImpl.kt @@ -5,6 +5,7 @@ import android.net.Uri import androidx.core.net.toUri import com.afternote.core.common.di.IoDispatcher import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.model.UploadedFile import com.afternote.core.domain.repository.VideoUploadRepository import com.afternote.core.network.dto.PresignedUrlRequestDto import com.afternote.core.network.model.requireData @@ -34,7 +35,7 @@ internal class VideoUploadRepositoryImpl override suspend fun upload( uriString: String, directory: String, - ): Result = + ): Result = runCatchingCancellable { val uri = uriString.toUri() val extension = videoExtensionFromUri(uri) @@ -85,7 +86,7 @@ internal class VideoUploadRepositoryImpl } } - presigned.fileUrl + UploadedFile(fileUrl = presigned.fileUrl, fileKey = presigned.fileKey) } finally { tempFile.delete() } diff --git a/core/domain/src/main/java/com/afternote/core/domain/model/UploadedFile.kt b/core/domain/src/main/java/com/afternote/core/domain/model/UploadedFile.kt new file mode 100644 index 0000000000..bff641c87b --- /dev/null +++ b/core/domain/src/main/java/com/afternote/core/domain/model/UploadedFile.kt @@ -0,0 +1,15 @@ +package com.afternote.core.domain.model + +/** + * 업로드가 끝난 파일. presigned 응답이 내려주는 두 값을 그대로 들고 있는다. + * + * 종전에는 [fileUrl] 하나만 돌려줘, 키가 필요한 호출부가 URL 앞을 잘라 역산했다. 그 역산은 + * `fileUrl == "<스킴>://<호스트>/" + fileKey` 를 가정하는데, 경로 프리픽스나 쿼리스트링이 + * 붙는 순간 조용히 어긋난 키가 나간다 (#1017). 두 값을 다 실어 보내 추측을 없앤다. + */ +data class UploadedFile( + /** 화면에 그대로 띄울 수 있는 전체 URL. */ + val fileUrl: String, + /** 서버가 파일을 식별하는 키. */ + val fileKey: String, +) diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/PhotoUploadRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/PhotoUploadRepository.kt index 3bfeb8160a..08f22d5d87 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/PhotoUploadRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/PhotoUploadRepository.kt @@ -1,7 +1,9 @@ package com.afternote.core.domain.repository +import com.afternote.core.domain.model.UploadedFile + /** - * Core photo upload: content URI → presigned URL → S3 PUT → file URL. + * Core photo upload: content URI → presigned URL → S3 PUT → uploaded file. * Reusable for profile image, memorial photo, and any other image upload that uses * POST /files/presigned-url and the same S3 flow. */ @@ -9,10 +11,10 @@ fun interface PhotoUploadRepository { /** * @param uriString content URI of the selected image (e.g. from gallery). * @param directory target directory for the file (e.g. "profiles", "afternotes"). - * @return Success with image URL (https) or failure. + * @return Success with the uploaded file — full URL and file key both — or failure. */ suspend fun upload( uriString: String, directory: String, - ): Result + ): Result } diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/VideoUploadRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/VideoUploadRepository.kt index 62df1424a9..08bddce86d 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/VideoUploadRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/VideoUploadRepository.kt @@ -1,7 +1,9 @@ package com.afternote.core.domain.repository +import com.afternote.core.domain.model.UploadedFile + /** - * Core video upload: content URI → presigned URL → S3 PUT → file URL. + * Core video upload: content URI → presigned URL → S3 PUT → uploaded file. * Reusable for memorial video, and any other video upload that uses * POST /files/presigned-url and the same S3 flow. */ @@ -9,10 +11,10 @@ fun interface VideoUploadRepository { /** * @param uriString content URI of the selected video (e.g. from gallery / Photo Picker). * @param directory target directory for the file (e.g. "afternotes"). - * @return Success with video URL (https) or failure. + * @return Success with the uploaded file — full URL and file key both — or failure. */ suspend fun upload( uriString: String, directory: String, - ): Result + ): Result } diff --git a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakePhotoUploadRepository.kt b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakePhotoUploadRepository.kt index 23f29275fc..666f60ee1d 100644 --- a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakePhotoUploadRepository.kt +++ b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakePhotoUploadRepository.kt @@ -1,26 +1,29 @@ package com.afternote.core.domain.testing +import com.afternote.core.domain.model.UploadedFile import com.afternote.core.domain.repository.PhotoUploadRepository import java.util.concurrent.CopyOnWriteArrayList -/** [PhotoUploadRepository] fake 정본. 업로드 요청을 기록하고 기본 CDN URL을 돌려준다. */ +/** [PhotoUploadRepository] fake 정본. 업로드 요청을 기록하고 기본 업로드 결과를 돌려준다. */ class FakePhotoUploadRepository( var uploadedUrl: String = DEFAULT_UPLOADED_URL, - var onUpload: (suspend (String, String) -> Result)? = null, + var uploadedKey: String = DEFAULT_UPLOADED_KEY, + var onUpload: (suspend (String, String) -> Result)? = null, ) : PhotoUploadRepository { val uploads = CopyOnWriteArrayList>() override suspend fun upload( uriString: String, directory: String, - ): Result { + ): Result { uploads += uriString to directory onUpload?.let { return it(uriString, directory) } - return Result.success(uploadedUrl) + return Result.success(UploadedFile(fileUrl = uploadedUrl, fileKey = uploadedKey)) } companion object { const val DEFAULT_UPLOADED_URL = "https://cdn.test/uploaded" + const val DEFAULT_UPLOADED_KEY = "uploads/test/uploaded" fun strict(): FakePhotoUploadRepository = FakePhotoUploadRepository( diff --git a/core/network/src/test/kotlin/com/afternote/core/network/dto/PresignedUrlContractTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/dto/PresignedUrlContractTest.kt index eb4b6d6fcf..6f2c2b58d1 100644 --- a/core/network/src/test/kotlin/com/afternote/core/network/dto/PresignedUrlContractTest.kt +++ b/core/network/src/test/kotlin/com/afternote/core/network/dto/PresignedUrlContractTest.kt @@ -1,6 +1,7 @@ package com.afternote.core.network.dto import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -30,6 +31,27 @@ class PresignedUrlContractTest { assertTrue("파일 크기가 빠지면 서버가 400 으로 거절한다", encoded.contains("\"contentLength\":357")) } + @Test + fun `응답의 fileKey 와 fileUrl 을 둘 다 읽는다`() { + // 업로드 결과를 쓰는 쪽은 미리보기용 전체 URL 과 파일 키를 둘 다 필요로 한다. 한쪽만 읽으면 + // 나머지를 문자열로 역산하게 되고, 호스트 뒤에 경로가 더 붙는 순간 어긋난다 (#1017). + val decoded = + json.decodeFromString( + PresignedUrlDto.serializer(), + """ + { + "presignedUrl": "https://s3.example/put?sig=abc", + "fileKey": "mindrecords/staging/13/uuid.png", + "fileUrl": "https://cdn.example/assets/mindrecords/staging/13/uuid.png", + "contentType": "image/png" + } + """, + ) + + assertEquals("mindrecords/staging/13/uuid.png", decoded.fileKey) + assertEquals("https://cdn.example/assets/mindrecords/staging/13/uuid.png", decoded.fileUrl) + } + @Test fun `크기가 0 이어도 필드는 생략되지 않는다`() { // 기본값을 두면 kotlinx 가 기본값과 같은 값을 생략할 수 있다 — 크기는 언제나 명시한다. 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 index 85ac597210..353c438f1a 100644 --- 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 @@ -39,8 +39,8 @@ class MemorialMediaUploadRepositoryImpl is MediaInput.Local -> { when (kind) { - MediaKind.PHOTO -> photoUploadRepository.upload(input.uri, DIRECTORY_AFTERNOTES) - MediaKind.VIDEO -> videoUploadRepository.upload(input.uri, DIRECTORY_AFTERNOTES) + MediaKind.PHOTO -> photoUploadRepository.upload(input.uri, DIRECTORY_AFTERNOTES).map { it.fileUrl } + MediaKind.VIDEO -> videoUploadRepository.upload(input.uri, DIRECTORY_AFTERNOTES).map { it.fileUrl } } } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt index 495c97c1b9..821469024a 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt @@ -200,10 +200,10 @@ class DailyQuestionWriteViewModel _uiState.update { it.copy(isUploadingImage = true, imageUploadError = null) } return photoUploadRepository .upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY) - .onSuccess { url -> + .onSuccess { uploaded -> // 계약에 imageUrl 이 없어 상태로 들지 않는다 — 본문 img 로 들어가고, // 제출 직전 fileKey 로 바뀔 수 있게 기억만 해 둔다 (#549). - uploadedImageUrls += url + uploadedImageUrls += uploaded.fileUrl _uiState.update { it.copy(isUploadingImage = false) } }.onFailure { // null 로 흡수하면 사용자는 이미지가 붙은 줄 알고 저장한다 (#716). @@ -214,6 +214,7 @@ class DailyQuestionWriteViewModel ) } }.getOrNull() + ?.fileUrl } /** diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt index 221b0dfb67..2dc91250b6 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt @@ -116,11 +116,11 @@ class DiaryWriteViewModel _uiState.update { it.copy(isUploadingImage = true, imageUploadError = null) } return photoUploadRepository .upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY) - .onSuccess { url -> + .onSuccess { uploaded -> // 제출 직전 fileKey 로 바꿀 대상이다 (#1016). - uploadedImageUrls += url + uploadedImageUrls += uploaded.fileUrl _uiState.update { - val withUrl = if (it.imageUrl == null) it.copy(imageUrl = url) else it + val withUrl = if (it.imageUrl == null) it.copy(imageUrl = uploaded.fileUrl) else it withUrl.copy(isUploadingImage = false) } }.onFailure { @@ -132,6 +132,7 @@ class DiaryWriteViewModel ) } }.getOrNull() + ?.fileUrl } fun submit(isDraft: Boolean = false) { diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt index 9ffd85f051..974649175a 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt @@ -111,7 +111,7 @@ class DailyQuestionResumeDraftTest { DailyQuestionWriteViewModel( savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, - photoUploadRepository = FakePhotoUploadRepository(uploadedUrl = ""), + photoUploadRepository = FakePhotoUploadRepository(uploadedUrl = "", uploadedKey = ""), // 툴바 카운트는 이 테스트의 관심사가 아니다 — 같은 저장소를 넘겨 0건으로 둔다 (#769). draftLoader = MindRecordDraftLoader( @@ -151,7 +151,7 @@ class DailyQuestionResumeDraftTest { DailyQuestionWriteViewModel( savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, - photoUploadRepository = FakePhotoUploadRepository(uploadedUrl = ""), + photoUploadRepository = FakePhotoUploadRepository(uploadedUrl = "", uploadedKey = ""), // 툴바 카운트는 이 테스트의 관심사가 아니다 — 같은 저장소를 넘겨 0건으로 둔다 (#769). draftLoader = MindRecordDraftLoader( diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt index a770008153..0380146b47 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt @@ -110,7 +110,10 @@ class DailyQuestionWriteViewModelTest { DailyQuestionWriteViewModel( SavedStateHandle(emptyMap()), repository, - FakePhotoUploadRepository(uploadedUrl = "https://cdn/just-picked.jpg"), + FakePhotoUploadRepository( + uploadedUrl = "https://cdn/just-picked.jpg", + uploadedKey = "mindrecords/1/just-picked.jpg", + ), noopDraftLoader(), ) @@ -135,7 +138,10 @@ class DailyQuestionWriteViewModelTest { DailyQuestionWriteViewModel( SavedStateHandle(emptyMap()), repository, - FakePhotoUploadRepository(uploadedUrl = "https://cdn/picked.jpg"), + FakePhotoUploadRepository( + uploadedUrl = "https://cdn/picked.jpg", + uploadedKey = "mindrecords/staging/13/picked.jpg", + ), noopDraftLoader(), ) @@ -225,7 +231,10 @@ class DailyQuestionWriteViewModelTest { DailyQuestionWriteViewModel( SavedStateHandle(emptyMap()), repository, - FakePhotoUploadRepository(uploadedUrl = "https://cdn.example.net/mindrecords/staging/13/a.png"), + FakePhotoUploadRepository( + uploadedUrl = "https://cdn.example.net/mindrecords/staging/13/a.png", + uploadedKey = "mindrecords/staging/13/a.png", + ), noopDraftLoader(), ) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt index a1452afdf7..e06a8ec8cc 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt @@ -73,13 +73,18 @@ class DiaryWriteImageKeyTest { DiaryWriteViewModel( savedStateHandle = SavedStateHandle(emptyMap()), repository = RecordingDiaryRepository(onCreate), - photoUploadRepository = FakePhotoUploadRepository(uploadedUrl = UPLOADED_URL), + photoUploadRepository = + FakePhotoUploadRepository( + uploadedUrl = UPLOADED_URL, + uploadedKey = UPLOADED_KEY, + ), userRepository = noReceiverUserRepository(), draftLoader = MindRecordDraftLoader(RecordingDiaryRepository {}, NoDailyQuestionRepository), ) private companion object { const val UPLOADED_URL = "https://cdn.example.net/mindrecords/staging/13/a.png" + const val UPLOADED_KEY = "mindrecords/staging/13/a.png" } } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt index a832f68f74..57a8edd6d0 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt @@ -1,6 +1,7 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.model.UploadedFile import com.afternote.core.domain.testing.FakePhotoUploadRepository import com.afternote.feature.mindrecord.domain.model.DailyQuestion import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload @@ -107,7 +108,7 @@ class MindRecordFailureRecoveryTest { runTest(dispatcher) { // 툴바 임시저장은 `enabled` 없는 clickable 이라 canSubmit 을 우회한다 — 그래서 // 상태만으로는 부족하고 submit() 이 직접 막아야 한다 (리뷰 지적). - val uploadGate = CompletableDeferred>() + val uploadGate = CompletableDeferred>() val repository = NeverSubmittingRepository() val viewModel = DailyQuestionWriteViewModel( @@ -137,7 +138,14 @@ class MindRecordFailureRecoveryTest { ) // 업로드를 끝내 코루틴을 정리한다. - uploadGate.complete(Result.success("https://cdn.example.com/a.png")) + uploadGate.complete( + Result.success( + UploadedFile( + fileUrl = "https://cdn.example.com/a.png", + fileKey = "mindrecords/staging/13/a.png", + ), + ), + ) uploading.join() } } diff --git a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/usecase/ResolveTimeLetterBlocksUseCase.kt b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/usecase/ResolveTimeLetterBlocksUseCase.kt index d63c546d04..e7a1f99ff8 100644 --- a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/usecase/ResolveTimeLetterBlocksUseCase.kt +++ b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/usecase/ResolveTimeLetterBlocksUseCase.kt @@ -36,6 +36,7 @@ class ResolveTimeLetterBlocksUseCase photoUploadRepository .upload(input.uriString, TIME_LETTER_DIRECTORY) .getOrElse { throw it } + .fileUrl } else { input.uriString } diff --git a/feature/timeletter/domain/src/test/kotlin/com/afternote/feature/timeletter/domain/usecase/ResolveTimeLetterBlocksUseCaseTest.kt b/feature/timeletter/domain/src/test/kotlin/com/afternote/feature/timeletter/domain/usecase/ResolveTimeLetterBlocksUseCaseTest.kt index 36b462b526..a5213d5442 100644 --- a/feature/timeletter/domain/src/test/kotlin/com/afternote/feature/timeletter/domain/usecase/ResolveTimeLetterBlocksUseCaseTest.kt +++ b/feature/timeletter/domain/src/test/kotlin/com/afternote/feature/timeletter/domain/usecase/ResolveTimeLetterBlocksUseCaseTest.kt @@ -1,5 +1,6 @@ package com.afternote.feature.timeletter.domain.usecase +import com.afternote.core.domain.model.UploadedFile import com.afternote.core.domain.testing.FakePhotoUploadRepository import com.afternote.feature.timeletter.domain.model.BlockInput import com.afternote.feature.timeletter.domain.model.TimeLetterBlockType @@ -12,7 +13,15 @@ class ResolveTimeLetterBlocksUseCaseTest { @Test fun `local media is uploaded and uses remote url`() = runBlocking { - val repository = photoUploadRepository(Result.success("https://cdn/image.jpg")) + val repository = + photoUploadRepository( + Result.success( + UploadedFile( + fileUrl = "https://cdn/image.jpg", + fileKey = "timeletters/9/stored.jpg", + ), + ), + ) val useCase = ResolveTimeLetterBlocksUseCase(repository) val blocks = @@ -34,7 +43,7 @@ class ResolveTimeLetterBlocksUseCaseTest { @Test fun `remote media is preserved without upload`() = runBlocking { - val repository = photoUploadRepository(Result.success("unused")) + val repository = photoUploadRepository(Result.success(UNUSED_UPLOAD)) val useCase = ResolveTimeLetterBlocksUseCase(repository) val blocks = @@ -55,7 +64,7 @@ class ResolveTimeLetterBlocksUseCaseTest { @Test fun `blank text is removed and block order remains contiguous`() = runBlocking { - val useCase = ResolveTimeLetterBlocksUseCase(photoUploadRepository(Result.success("unused"))) + val useCase = ResolveTimeLetterBlocksUseCase(photoUploadRepository(Result.success(UNUSED_UPLOAD))) val blocks = useCase( @@ -73,7 +82,15 @@ class ResolveTimeLetterBlocksUseCaseTest { @Test fun `image audio and file local media are all uploaded`() = runBlocking { - val repository = photoUploadRepository(Result.success("https://cdn/uploaded")) + val repository = + photoUploadRepository( + Result.success( + UploadedFile( + fileUrl = "https://cdn/uploaded", + fileKey = "timeletters/9/uploaded", + ), + ), + ) val useCase = ResolveTimeLetterBlocksUseCase(repository) useCase( @@ -108,7 +125,7 @@ class ResolveTimeLetterBlocksUseCaseTest { @Test fun `unsupported local media type is rejected before upload`() { - val repository = photoUploadRepository(Result.success("unused")) + val repository = photoUploadRepository(Result.success(UNUSED_UPLOAD)) val useCase = ResolveTimeLetterBlocksUseCase(repository) val result = @@ -126,6 +143,9 @@ class ResolveTimeLetterBlocksUseCaseTest { assertTrue(repository.uploads.isEmpty()) } - private fun photoUploadRepository(result: Result): FakePhotoUploadRepository = + private fun photoUploadRepository(result: Result): FakePhotoUploadRepository = FakePhotoUploadRepository(onUpload = { _, _ -> result }) } + +/** 업로드가 일어나면 안 되는 시나리오용 — 값 자체엔 의미가 없다. */ +private val UNUSED_UPLOAD = UploadedFile(fileUrl = "unused", fileKey = "unused")