diff --git a/src/main/java/com/mr/domain/learning/controller/LearningController.java b/src/main/java/com/mr/domain/learning/controller/LearningController.java index d59dbafe..4fd02655 100644 --- a/src/main/java/com/mr/domain/learning/controller/LearningController.java +++ b/src/main/java/com/mr/domain/learning/controller/LearningController.java @@ -87,7 +87,7 @@ public ApiResponse getCurricu @Operation( summary = "학습 단계별 조회 API", - description = "단계별 이론 설명, 연습 팁, 모범 연주 예시(있으면), 코드 예시를 조회합니다." + description = "단계별 이론 설명, 연습 팁, 모범 연주 예시(있으면 Presigned GET URL 포함), 코드 예시를 조회합니다." ) @GetMapping("/{learningId}/steps/{learningStepId}") public ApiResponse getStepDetail( diff --git a/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java b/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java index f193560b..72c8920a 100644 --- a/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java +++ b/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java @@ -45,7 +45,8 @@ public record StepDetailResultDTO( List chordExamples ) { public static StepDetailResultDTO of(Learning learning, LearningStep step, - PlayingExample playingExample, List chordExamples) { + PlayingExample playingExample, String audioUrl, + List chordExamples) { return new StepDetailResultDTO( learning.getId(), step.getId(), @@ -55,7 +56,7 @@ public static StepDetailResultDTO of(Learning learning, LearningStep step, step.getTitle(), step.getContent(), step.getPracticeTip(), - playingExample != null ? ModelPerformance.from(playingExample) : null, + playingExample != null ? ModelPerformance.from(playingExample, audioUrl) : null, chordExamples.stream().map(ChordExampleItem::from).toList() ); } @@ -69,17 +70,20 @@ public record ModelPerformance( @Schema(description = "예시 설명", example = "프로 연주자의 응용 사례") String description, - @Schema(description = "화면에서 재생할 오디오 URL", example = "https://cdn.example.com/audio/11th-tension-example.mp3") + @Schema( + description = "비공개 S3 모범 연주 재생용 Presigned GET URL. 일정 시간 후 만료", + example = "https://example-bucket.s3.ap-northeast-2.amazonaws.com/playing_example/triads_step1.mp3?X-Amz-Expires=600&X-Amz-Signature=example" + ) String audioUrl, @Schema(description = "재생 시간(초)", example = "154") Integer durationSeconds ) { - public static ModelPerformance from(PlayingExample playingExample) { + public static ModelPerformance from(PlayingExample playingExample, String audioUrl) { return new ModelPerformance( playingExample.getTitle(), playingExample.getDescription(), - playingExample.getAudioFileUrl(), + audioUrl, playingExample.getPlayingSeconds() != null ? playingExample.getPlayingSeconds().intValue() : null ); } diff --git a/src/main/java/com/mr/domain/learning/entity/PlayingExample.java b/src/main/java/com/mr/domain/learning/entity/PlayingExample.java index bbf462f7..2c6824b5 100644 --- a/src/main/java/com/mr/domain/learning/entity/PlayingExample.java +++ b/src/main/java/com/mr/domain/learning/entity/PlayingExample.java @@ -35,43 +35,36 @@ public class PlayingExample extends BaseCreatedEntity { @JoinColumn(name = "learning_step_id", nullable = false, unique = true) private LearningStep learningStep; - // 제목 @Column(name = "title", nullable = false, length = 100) private String title; - // 미디 파일 데이터 @JdbcTypeCode(SqlTypes.JSON) @Column(name = "midi_data", nullable = false, columnDefinition = "JSON") private String midiData; - // 오디오 파일 - @Column(name = "audio_file_url", nullable = false, length = 255) - private String audioFileUrl; + @Column(name = "audio_object_key", nullable = false, length = 255) + private String audioObjectKey; - // bpm @Column(name = "bpm") private Integer bpm; - // key @Column(name = "key_signature", length = 20) private String keySignature; - // 설명 @Column(name = "description", columnDefinition = "TEXT") private String description; - // 재생 시간 (초 단위 저장) @Column(name = "playing_seconds") private Long playingSeconds; @Builder(access = AccessLevel.PRIVATE) private PlayingExample(LearningStep learningStep, String title, String midiData, - String audioFileUrl, Integer bpm, String keySignature, + String audioObjectKey, Integer bpm, String keySignature, String description, Long playingSeconds) { this.learningStep = learningStep; this.title = title; this.midiData = midiData; - this.audioFileUrl = audioFileUrl; + this.audioObjectKey = audioObjectKey; this.bpm = bpm; this.keySignature = keySignature; this.description = description; @@ -79,13 +72,13 @@ private PlayingExample(LearningStep learningStep, String title, String midiData, } public static PlayingExample create(LearningStep learningStep, String title, String midiData, - String audioFileUrl, Integer bpm, String keySignature, + String audioObjectKey, Integer bpm, String keySignature, String description, Long playingSeconds) { return PlayingExample.builder() .learningStep(learningStep) .title(title) .midiData(midiData) - .audioFileUrl(audioFileUrl) + .audioObjectKey(audioObjectKey) .bpm(bpm) .keySignature(keySignature) .description(description) @@ -93,12 +86,12 @@ public static PlayingExample create(LearningStep learningStep, String title, Str .build(); } - public void updatePlayingExample(String title, String midiData, String audioFileUrl, + public void updatePlayingExample(String title, String midiData, String audioObjectKey, Integer bpm, String keySignature, String description, Long playingSeconds) { this.title = title; this.midiData = midiData; - this.audioFileUrl = audioFileUrl; + this.audioObjectKey = audioObjectKey; this.bpm = bpm; this.keySignature = keySignature; this.description = description; diff --git a/src/main/java/com/mr/domain/learning/service/LearningService.java b/src/main/java/com/mr/domain/learning/service/LearningService.java index 55b6b609..f5821962 100644 --- a/src/main/java/com/mr/domain/learning/service/LearningService.java +++ b/src/main/java/com/mr/domain/learning/service/LearningService.java @@ -27,6 +27,8 @@ import com.mr.domain.user.repository.UserRepository; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.event.NotificationEvent; +import com.mr.global.file.s3.enums.S3FileType; +import com.mr.global.file.s3.service.S3FileService; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -50,7 +52,6 @@ @Transactional(readOnly = true) public class LearningService { - // 추천 학습 카드 최대 개수 private static final int RECOMMENDED_LEARNING_LIMIT = 2; private static final List RECOMMENDATION_DIFFICULTY_ORDER = List.of(LearningDifficulty.BEGINNER, LearningDifficulty.INTERMEDIATE, LearningDifficulty.ADVANCED); @@ -60,11 +61,10 @@ public class LearningService { private final LearningRepository learningRepository; private final PlayingExampleRepository playingExampleRepository; private final ChordExampleRepository chordExampleRepository; - // 임시 작명 + private final S3FileService s3FileService; private final UserRepository userRepository; private final ApplicationEventPublisher eventPublisher; - // 학습 결과 저장 @Transactional public LearningResultResponseDTO.SaveResultResultDTO saveResult( Long userId, @@ -147,7 +147,6 @@ public LearningProgressResponseDTO.ProgressResultDTO getLearningProgress( return LearningProgressResponseDTO.ProgressResultDTO.of(learningId, progressRate); } - // 학습 단계별 연습 실행 정보 조회 public LearningPracticeDataResponseDTO.PracticeDataResultDTO getPracticeData( Long learningId, Long learningStepId @@ -161,7 +160,6 @@ public LearningPracticeDataResponseDTO.PracticeDataResultDTO getPracticeData( return LearningPracticeDataResponseDTO.PracticeDataResultDTO.from(playingExample); } - // 학습 주제(THEORY) 전체보기 public LearningTheoryListResponseDTO.TheoryListResultDTO getTheoryList(Long userId, String difficulty) { LearningDifficulty parsedDifficulty = parseDifficulty(difficulty); ensureUserExists(userId); @@ -172,7 +170,6 @@ public LearningTheoryListResponseDTO.TheoryListResultDTO getTheoryList(Long user return LearningTheoryListResponseDTO.TheoryListResultDTO.from(learnings); } - // 학습 커리큘럼 조회 public LearningCurriculumResponseDTO.CurriculumResultDTO getCurriculum(Long userId, Long learningId) { ensureUserExists(userId); @@ -206,7 +203,6 @@ private LearningCurriculumResponseDTO.StepItem toStepItem(LearningStep step, Use return LearningCurriculumResponseDTO.StepItem.of(step, status, score); } - // 학습 단계별 조회 public LearningStepDetailResponseDTO.StepDetailResultDTO getStepDetail(Long learningId, Long learningStepId) { Learning learning = getActiveLearningOrThrow(learningId); LearningStep learningStep = getLearningStepOrThrow(learning, learningStepId); @@ -214,10 +210,22 @@ public LearningStepDetailResponseDTO.StepDetailResultDTO getStepDetail(Long lear PlayingExample playingExample = playingExampleRepository.findByLearningStep_Id(learningStepId).orElse(null); List chordExamples = chordExampleRepository.findByLearningStep_Id(learningStepId); - return LearningStepDetailResponseDTO.StepDetailResultDTO.of(learning, learningStep, playingExample, chordExamples); + String audioUrl = playingExample == null + ? null + : s3FileService.createPresignedDownload( + S3FileType.PLAYING_EXAMPLE, + playingExample.getAudioObjectKey() + ); + + return LearningStepDetailResponseDTO.StepDetailResultDTO.of( + learning, + learningStep, + playingExample, + audioUrl, + chordExamples + ); } - // 실전 반주법 패키지(ACCOMPANIMENT) 전체보기 public LearningAccompanimentListResponseDTO.AccompanimentListResultDTO getAccompanimentList(Long userId) { ensureUserExists(userId); @@ -229,7 +237,6 @@ public LearningAccompanimentListResponseDTO.AccompanimentListResultDTO getAccomp return LearningAccompanimentListResponseDTO.AccompanimentListResultDTO.of(items); } - // 학습 홈 조회 public LearningHomeResponseDTO.HomeResultDTO getHome(Long userId) { ensureUserExists(userId); diff --git a/src/main/java/com/mr/global/file/s3/enums/S3FileType.java b/src/main/java/com/mr/global/file/s3/enums/S3FileType.java index 068a224b..1a53f5b0 100644 --- a/src/main/java/com/mr/global/file/s3/enums/S3FileType.java +++ b/src/main/java/com/mr/global/file/s3/enums/S3FileType.java @@ -7,8 +7,10 @@ @RequiredArgsConstructor public enum S3FileType { - RECORDING("recordings"), - BACKING_TRACK("backing-tracks"); + RECORDING("recordings", true), + BACKING_TRACK("backing-tracks", true), + PLAYING_EXAMPLE("playing_example", false); private final String prefix; + private final boolean ownerScoped; } diff --git a/src/main/java/com/mr/global/file/s3/service/S3FileService.java b/src/main/java/com/mr/global/file/s3/service/S3FileService.java index b0674871..c0e59e15 100644 --- a/src/main/java/com/mr/global/file/s3/service/S3FileService.java +++ b/src/main/java/com/mr/global/file/s3/service/S3FileService.java @@ -48,6 +48,7 @@ public PresignedUrlUpload createPresignedUpload( ){ validateOwnerId(ownerId); + validateOwnerScopedFileType(fileType); validateUploadCommand(command); String normalizedContentType = ContentTypeUtils.normalize(command.contentType()); @@ -119,6 +120,7 @@ public PresignedUrlUpload createPresignedUpload( public ValidatedFile validateUploadedFile(Long ownerId, S3FileType fileType, String objectKey ) { validateOwnerId(ownerId); + validateOwnerScopedFileType(fileType); validateObjectKey(ownerId, fileType, objectKey); HeadObjectResponse headObject = getHeadObject(objectKey); @@ -145,12 +147,29 @@ public ValidatedFile validateUploadedFile(Long ownerId, S3FileType fileType, Str ); } - // 파일 조회용 Presigned GET URL을 발급 public String createPresignedDownload(Long ownerId, S3FileType fileType, String objectKey ) { validateOwnerId(ownerId); + validateOwnerScopedFileType(fileType); validateObjectKey(ownerId, fileType, objectKey); + return presignDownload(objectKey); + } + + /** + * 사용자 소유자가 없는 공용 콘텐츠의 조회용 Presigned GET URL을 발급합니다. + */ + public String createPresignedDownload(S3FileType fileType, String objectKey) { + if (fileType == null || fileType.isOwnerScoped()) { + throw new GeneralException(S3ErrorStatus.INVALID_OBJECT_KEY); + } + + validateObjectKey(fileType, objectKey); + + return presignDownload(objectKey); + } + + private String presignDownload(String objectKey) { GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(s3Properties.bucket()) @@ -177,8 +196,7 @@ public String createPresignedDownload(Long ownerId, S3FileType fileType, String } catch (SdkException exception) { log.error( - "S3 Presigned GET URL 발급에 실패했습니다. ownerId={}, objectKey={}", - ownerId, + "S3 Presigned GET URL 발급에 실패했습니다. objectKey={}", objectKey, exception ); @@ -242,6 +260,12 @@ private void validateOwnerId(Long ownerId) { } } + private void validateOwnerScopedFileType(S3FileType fileType) { + if (fileType == null || !fileType.isOwnerScoped()) { + throw new GeneralException(S3ErrorStatus.INVALID_OBJECT_KEY); + } + } + private void validateUploadCommand( FileUploadCommand command ) { @@ -303,4 +327,14 @@ private void validateObjectKey(Long ownerId, S3FileType fileType, String objectK throw new GeneralException(S3ErrorStatus.INVALID_OBJECT_KEY); } } + + private void validateObjectKey(S3FileType fileType, String objectKey) { + if (objectKey == null || objectKey.isBlank()) { + throw new GeneralException(S3ErrorStatus.INVALID_OBJECT_KEY); + } + + if (!objectKeyGenerator.belongsToFileType(fileType, objectKey)) { + throw new GeneralException(S3ErrorStatus.INVALID_OBJECT_KEY); + } + } } diff --git a/src/main/java/com/mr/global/file/s3/service/S3ObjectKeyGenerator.java b/src/main/java/com/mr/global/file/s3/service/S3ObjectKeyGenerator.java index 5af66bf6..d70167ae 100644 --- a/src/main/java/com/mr/global/file/s3/service/S3ObjectKeyGenerator.java +++ b/src/main/java/com/mr/global/file/s3/service/S3ObjectKeyGenerator.java @@ -46,7 +46,6 @@ public String generate( Instant now = Instant.now(); - String generatedFileName = "%s_%s.%s".formatted( TIME_FORMATTER.format(now), @@ -72,6 +71,14 @@ public boolean belongsToOwner(Long ownerId, S3FileType fileType, String objectKe return objectKey.startsWith(expectedPrefix); } + public boolean belongsToFileType(S3FileType fileType, String objectKey) { + if (fileType == null || objectKey == null) { + return false; + } + + return objectKey.startsWith(fileType.getPrefix() + "/"); + } + private String createShortUuid() { return UUID.randomUUID() .toString() @@ -84,16 +91,14 @@ private String resolveExtension( String contentType ) { - // contentType 정규화 및 대표 확장자 추출 String extensionByContentType = resolveExtensionByContentType(contentType); - // originalFileName에 확장자가 있는 경우, contentType 기준 확장자와 일치하는지 검증 (선택적) + // 파일명 확장자와 Content-Type이 서로 다른 요청을 거부해 메타데이터 불일치를 막는다. String fileExtension = extractExtension(originalFileName); if (fileExtension != null && !fileExtension.equalsIgnoreCase(extensionByContentType)) { throw new GeneralException(S3ErrorStatus.UNSUPPORTED_FILE_EXTENSION); } - // 최종적으로 contentType 기반의 올바른 대표 확장자 반환 return extensionByContentType; } diff --git a/src/main/resources/db/migration/V9__migrate_playing_example_audio_file_url_to_object_key.sql b/src/main/resources/db/migration/V9__migrate_playing_example_audio_file_url_to_object_key.sql new file mode 100644 index 00000000..30543845 --- /dev/null +++ b/src/main/resources/db/migration/V9__migrate_playing_example_audio_file_url_to_object_key.sql @@ -0,0 +1,34 @@ +-- 1. 컬럼명 변경 +ALTER TABLE playing_example + RENAME COLUMN audio_file_url TO audio_object_key; + +-- 2. 기존 URL 값에서 S3 Object Key만 추출 +UPDATE playing_example +SET audio_object_key = ltrim( + regexp_replace( + audio_object_key, + '^(https?://[^/]+/)?([^?#]*).*$', + '\2' + ), + '/' +) +WHERE audio_object_key IS NOT NULL + AND btrim(audio_object_key) <> ''; + +-- 3. URL이나 잘못된 prefix가 남아 있으면 마이그레이션을 중단 +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM playing_example + WHERE audio_object_key IS NULL + OR btrim(audio_object_key) = '' + OR audio_object_key LIKE 'http%' + OR audio_object_key LIKE '%?%' + OR audio_object_key LIKE '%X-Amz%' + OR audio_object_key NOT LIKE 'playing_example/%' + ) THEN + RAISE EXCEPTION 'playing_example audio_object_key migration validation failed'; + END IF; +END +$$; diff --git a/src/test/java/com/mr/domain/learning/service/LearningServiceTest.java b/src/test/java/com/mr/domain/learning/service/LearningServiceTest.java index 517c3231..4320e9a0 100644 --- a/src/test/java/com/mr/domain/learning/service/LearningServiceTest.java +++ b/src/test/java/com/mr/domain/learning/service/LearningServiceTest.java @@ -24,6 +24,8 @@ import com.mr.domain.learning.dto.req.LearningResultSaveRequestDTO; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.event.NotificationEvent; +import com.mr.global.file.s3.enums.S3FileType; +import com.mr.global.file.s3.service.S3FileService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -42,6 +44,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; @@ -63,6 +66,8 @@ class LearningServiceTest { @Mock private ChordExampleRepository chordExampleRepository; @Mock + private S3FileService s3FileService; + @Mock private UserRepository userRepository; @Mock private ApplicationEventPublisher eventPublisher; @@ -247,10 +252,14 @@ void getStepDetail_success_includesModelPerformanceAndChordExamples() { when(step.getPracticeTip()).thenReturn("연습 팁"); PlayingExample playingExample = mock(PlayingExample.class); + String audioObjectKey = "playing_example/11th.mp3"; + String presignedAudioUrl = "https://example.com/11th.mp3?X-Amz-Signature=test"; when(playingExample.getTitle()).thenReturn("11th Tension Notes Practice"); when(playingExample.getDescription()).thenReturn("프로 연주자의 응용 사례"); - when(playingExample.getAudioFileUrl()).thenReturn("https://cdn.example.com/audio/11th.mp3"); + when(playingExample.getAudioObjectKey()).thenReturn(audioObjectKey); when(playingExample.getPlayingSeconds()).thenReturn(154L); + when(s3FileService.createPresignedDownload(S3FileType.PLAYING_EXAMPLE, audioObjectKey)) + .thenReturn(presignedAudioUrl); ChordExample chordExample = mock(ChordExample.class); when(chordExample.getChordName()).thenReturn("Cmaj7"); @@ -267,12 +276,14 @@ void getStepDetail_success_includesModelPerformanceAndChordExamples() { assertThat(result.stepTitle()).isEqualTo("11th 텐션 노트 활용하기"); assertThat(result.modelPerformance()).isNotNull(); + assertThat(result.modelPerformance().audioUrl()).isEqualTo(presignedAudioUrl); assertThat(result.modelPerformance().durationSeconds()).isEqualTo(154); assertThat(result.chordExamples()).hasSize(1); assertThat(result.chordExamples().get(0).chordName()).isEqualTo("Cmaj7"); assertThat(result.chordExamples().get(0).noteNumbers()).containsExactly(60, 64, 67, 70, 77); assertThatThrownBy(() -> result.chordExamples().get(0).noteNumbers().add(1)) .isInstanceOf(UnsupportedOperationException.class); + verify(s3FileService).createPresignedDownload(S3FileType.PLAYING_EXAMPLE, audioObjectKey); } @Test @@ -299,6 +310,10 @@ void getStepDetail_noModelPerformance_returnsNull() { assertThat(result.modelPerformance()).isNull(); assertThat(result.chordExamples()).isEmpty(); + verify(s3FileService, never()).createPresignedDownload( + any(S3FileType.class), + anyString() + ); } @Test diff --git a/src/test/java/com/mr/global/file/s3/service/RecordingObjectKeyGeneratorTest.java b/src/test/java/com/mr/global/file/s3/service/RecordingObjectKeyGeneratorTest.java index aa37419b..880b04ad 100644 --- a/src/test/java/com/mr/global/file/s3/service/RecordingObjectKeyGeneratorTest.java +++ b/src/test/java/com/mr/global/file/s3/service/RecordingObjectKeyGeneratorTest.java @@ -328,4 +328,31 @@ void blankObjectKey() { .isFalse(); } } -} \ No newline at end of file + + @Nested + @DisplayName("Object Key 파일 타입 검증") + class ValidateObjectKeyFileType { + + @Test + @DisplayName("belongsToFileType - 파일 타입 prefix와 일치하면 true를 반환한다") + void belongsToFileType_matchingPrefix_returnsTrue() { + boolean result = objectKeyGenerator.belongsToFileType( + S3FileType.PLAYING_EXAMPLE, + "playing_example/triads_step1.mp3" + ); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("belongsToFileType - 파일 타입 prefix와 일치하지 않으면 false를 반환한다") + void belongsToFileType_mismatchedPrefix_returnsFalse() { + boolean result = objectKeyGenerator.belongsToFileType( + S3FileType.PLAYING_EXAMPLE, + "backing-tracks/1/triads_step1.mp3" + ); + + assertThat(result).isFalse(); + } + } +} diff --git a/src/test/java/com/mr/global/file/s3/service/S3FileServiceTest.java b/src/test/java/com/mr/global/file/s3/service/S3FileServiceTest.java index 560a9b84..d1f5a39c 100644 --- a/src/test/java/com/mr/global/file/s3/service/S3FileServiceTest.java +++ b/src/test/java/com/mr/global/file/s3/service/S3FileServiceTest.java @@ -60,6 +60,9 @@ class S3FileServiceTest { private static final String OTHER_OWNER_OBJECT_KEY = "recordings/2/2026-08-05/174500_test.mp3"; + private static final String PLAYING_EXAMPLE_OBJECT_KEY = + "playing_example/triads_step1.mp3"; + private static final String FILE_URL = "https://" + BUCKET @@ -350,6 +353,37 @@ void createPresignedUpload_invalidOwnerId() { ); } + @Test + @DisplayName("공용 콘텐츠 파일 타입이면 사용자 소유 경로의 Presigned PUT URL을 발급하지 않는다") + void createPresignedUpload_sharedContentFileType() { + FileUploadCommand command = + new FileUploadCommand( + "triads_step1.mp3", + CONTENT_TYPE, + FILE_SIZE + ); + + assertGeneralException( + () -> s3FileService.createPresignedUpload( + OWNER_ID, + S3FileType.PLAYING_EXAMPLE, + command + ), + S3ErrorStatus.INVALID_OBJECT_KEY + ); + + verify(objectKeyGenerator, never()) + .generate( + anyLong(), + any(S3FileType.class), + anyString(), + anyString() + ); + + verify(s3Presigner, never()) + .presignPutObject(any(PutObjectPresignRequest.class)); + } + @Test @DisplayName("업로드 명령이 null이면 예외가 발생한다") void createPresignedUpload_nullCommand() { @@ -646,6 +680,29 @@ void validateUploadedFile_invalidOwnerId() { ); } + @Test + @DisplayName("공용 콘텐츠 파일 타입이면 사용자 소유 경로에서 업로드 객체를 검증하지 않는다") + void validateUploadedFile_sharedContentFileType() { + assertGeneralException( + () -> s3FileService.validateUploadedFile( + OWNER_ID, + S3FileType.PLAYING_EXAMPLE, + PLAYING_EXAMPLE_OBJECT_KEY + ), + S3ErrorStatus.INVALID_OBJECT_KEY + ); + + verify(objectKeyGenerator, never()) + .belongsToOwner( + anyLong(), + any(S3FileType.class), + anyString() + ); + + verify(s3Client, never()) + .headObject(any(HeadObjectRequest.class)); + } + @Test @DisplayName("Object Key가 null이면 예외가 발생한다") void validateUploadedFile_nullObjectKey() { @@ -1060,6 +1117,102 @@ void createPresignedDownloadUrl_success() ); } + @Test + @DisplayName("createPresignedDownload - 공용 콘텐츠 Object Key이면 Presigned GET URL을 발급한다") + void createPresignedDownload_sharedContent_success() + throws Exception { + String downloadUrl = + "https://example.com/triads_step1.mp3?X-Amz-Signature=test"; + + when(objectKeyGenerator.belongsToFileType( + S3FileType.PLAYING_EXAMPLE, + PLAYING_EXAMPLE_OBJECT_KEY + )).thenReturn(true); + + when(s3Presigner.presignGetObject( + any(GetObjectPresignRequest.class) + )).thenReturn(presignedGetObjectRequest); + + when(presignedGetObjectRequest.url()) + .thenReturn(URI.create(downloadUrl).toURL()); + + String response = s3FileService.createPresignedDownload( + S3FileType.PLAYING_EXAMPLE, + PLAYING_EXAMPLE_OBJECT_KEY + ); + + assertThat(response).isEqualTo(downloadUrl); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(GetObjectPresignRequest.class); + + verify(s3Presigner).presignGetObject(captor.capture()); + assertThat(captor.getValue().getObjectRequest().key()) + .isEqualTo(PLAYING_EXAMPLE_OBJECT_KEY); + verify(objectKeyGenerator).belongsToFileType( + S3FileType.PLAYING_EXAMPLE, + PLAYING_EXAMPLE_OBJECT_KEY + ); + } + + @Test + @DisplayName("createPresignedDownload - 공용 콘텐츠 prefix와 다른 Object Key이면 Presigned GET URL을 발급하지 않는다") + void createPresignedDownload_sharedContent_invalidPrefix() { + when(objectKeyGenerator.belongsToFileType( + S3FileType.PLAYING_EXAMPLE, + OBJECT_KEY + )).thenReturn(false); + + assertGeneralException( + () -> s3FileService.createPresignedDownload( + S3FileType.PLAYING_EXAMPLE, + OBJECT_KEY + ), + S3ErrorStatus.INVALID_OBJECT_KEY + ); + + verify(s3Presigner, never()) + .presignGetObject(any(GetObjectPresignRequest.class)); + } + + @Test + @DisplayName("createPresignedDownload - 소유자 경로 파일 타입이면 Presigned GET URL을 발급하지 않는다") + void createPresignedDownload_ownerScopedFileType_throwsInvalidObjectKey() { + assertGeneralException( + () -> s3FileService.createPresignedDownload( + S3FileType.RECORDING, + OBJECT_KEY + ), + S3ErrorStatus.INVALID_OBJECT_KEY + ); + + verify(s3Presigner, never()) + .presignGetObject(any(GetObjectPresignRequest.class)); + } + + @Test + @DisplayName("createPresignedDownload - 공용 콘텐츠 파일 타입이면 사용자 소유 경로에서 Presigned GET URL을 발급하지 않는다") + void createPresignedDownload_sharedContentFileType_ownerPath() { + assertGeneralException( + () -> s3FileService.createPresignedDownload( + OWNER_ID, + S3FileType.PLAYING_EXAMPLE, + PLAYING_EXAMPLE_OBJECT_KEY + ), + S3ErrorStatus.INVALID_OBJECT_KEY + ); + + verify(objectKeyGenerator, never()) + .belongsToOwner( + anyLong(), + any(S3FileType.class), + anyString() + ); + + verify(s3Presigner, never()) + .presignGetObject(any(GetObjectPresignRequest.class)); + } + @Test @DisplayName("다른 소유자의 Object Key이면 Presigned GET URL을 발급하지 않는다") void createPresignedDownload_invalidObjectKey() { @@ -1146,4 +1299,4 @@ private void assertGeneralException( .isEqualTo(expectedErrorStatus); }); } -} \ No newline at end of file +}