diff --git a/src/main/java/com/mr/domain/playing/controller/PlayingController.java b/src/main/java/com/mr/domain/playing/controller/PlayingController.java index 518e2892..efdf20cd 100644 --- a/src/main/java/com/mr/domain/playing/controller/PlayingController.java +++ b/src/main/java/com/mr/domain/playing/controller/PlayingController.java @@ -7,6 +7,8 @@ import com.mr.domain.playing.dto.res.AnalysisContextResponse; import com.mr.domain.playing.dto.res.PlayingStartResponse; import com.mr.domain.playing.dto.res.RecordingUploadUrlResponse; +import com.mr.domain.playing.service.PlayingRecordingUploadService; +import com.mr.domain.playing.service.PlayingQueryService; import com.mr.domain.playing.service.PlayingService; import com.mr.global.apipayload.ApiResponse; import com.mr.global.security.principal.CustomUserDetails; @@ -30,6 +32,8 @@ public class PlayingController { private final PlayingService playingService; + private final PlayingRecordingUploadService playingFileService; + private final PlayingQueryService playingQueryService; @Operation( summary = "연주 세션 시작 API", @@ -61,7 +65,7 @@ public ApiResponse createRecordingUploadUrl( Long userId = userDetails.getUserId(); RecordingUploadUrlResponse response = - playingService.createRecordingUploadUrl(userId, playingId, request); + playingFileService.createRecordingUploadUrl(userId, playingId, request); return ApiResponse.onSuccess(response); } @@ -99,7 +103,7 @@ public ApiResponse getAnalysisContext( @Parameter(description = "연주 ID", example = "128") @PathVariable Long playingId ) { - AnalysisContextResponse response = playingService.getAnalysisContext( + AnalysisContextResponse response = playingQueryService.getAnalysisContext( userDetails.getUserId(), playingId ); diff --git a/src/main/java/com/mr/domain/playing/service/PlayingQueryService.java b/src/main/java/com/mr/domain/playing/service/PlayingQueryService.java new file mode 100644 index 00000000..25666501 --- /dev/null +++ b/src/main/java/com/mr/domain/playing/service/PlayingQueryService.java @@ -0,0 +1,89 @@ +package com.mr.domain.playing.service; + +import com.mr.domain.analysis.service.AnalysisBarCalculator; +import com.mr.domain.backingtrack.entity.BackingTrack; +import com.mr.domain.playing.dto.res.AnalysisContextResponse; +import com.mr.domain.playing.dto.res.PlayingDetailResponse; +import com.mr.domain.playing.entity.Playing; +import com.mr.domain.playing.exception.PlayingErrorStatus; +import com.mr.domain.playing.repository.PlayingRepository; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.file.s3.enums.S3FileType; +import com.mr.global.file.s3.service.S3FileService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PlayingQueryService { + + private final PlayingRepository playingRepository; + private final S3FileService s3FileService; + private final AnalysisBarCalculator analysisBarCalculator; + + @Transactional(readOnly = true) + public PlayingDetailResponse getPlayingDetail(Long userId, Long playingId) { + validatePlayingId(playingId); + + Playing playing = playingRepository.findByIdWithBackingTrack(playingId) + .orElseThrow(() -> new GeneralException(PlayingErrorStatus.PLAYING_NOT_FOUND)); + + playing.validatePlayingOwner(userId); + playing.validateCompleted(); + + String recordingFileUrl = + s3FileService.createPresignedDownload( + userId, + S3FileType.RECORDING, + playing.getRecordingObjectKey() + ); + + return PlayingDetailResponse.from(playing, recordingFileUrl); + } + + @Transactional(readOnly = true) + public AnalysisContextResponse getAnalysisContext(Long userId, Long playingId) { + validatePlayingId(playingId); + + Playing playing = playingRepository.findByIdWithBackingTrack(playingId) + .orElseThrow(() -> new GeneralException(PlayingErrorStatus.PLAYING_NOT_FOUND)); + + playing.validatePlayingOwner(userId); + playing.validateCompleted(); + if (playing.getBackingTrack() == null) { + throw new GeneralException(PlayingErrorStatus.BACKING_TRACK_NOT_FOUND); + } + + String recordingFileUrl = + s3FileService.createPresignedDownload( + userId, + S3FileType.RECORDING, + playing.getRecordingObjectKey() + ); + + BackingTrack backingTrack = playing.getBackingTrack(); + + String backingTrackAudioFileUrl = null; + + if (backingTrack.getAudioObjectKey() != null + && !backingTrack.getAudioObjectKey().isBlank()) { + + backingTrackAudioFileUrl = + s3FileService.createPresignedDownload( + backingTrack.getUser().getUserId(), + S3FileType.BACKING_TRACK, + backingTrack.getAudioObjectKey() + ); + } + + int totalBars = analysisBarCalculator.calculate(playing).totalBars(); + return AnalysisContextResponse.from(playing, totalBars, recordingFileUrl, backingTrackAudioFileUrl); + } + + private void validatePlayingId(Long playingId) { + if (playingId == null || playingId < 1) { + throw new GeneralException(PlayingErrorStatus.INVALID_PLAYING_ID); + } + } +} diff --git a/src/main/java/com/mr/domain/playing/service/PlayingRecordingUploadService.java b/src/main/java/com/mr/domain/playing/service/PlayingRecordingUploadService.java new file mode 100644 index 00000000..5810f5f8 --- /dev/null +++ b/src/main/java/com/mr/domain/playing/service/PlayingRecordingUploadService.java @@ -0,0 +1,44 @@ +package com.mr.domain.playing.service; + +import com.mr.domain.playing.dto.req.RecordingUploadUrlRequest; +import com.mr.domain.playing.dto.res.RecordingUploadUrlResponse; +import com.mr.domain.playing.entity.Playing; +import com.mr.domain.playing.exception.PlayingErrorStatus; +import com.mr.domain.playing.repository.PlayingRepository; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.file.s3.enums.S3FileType; +import com.mr.global.file.s3.service.S3FileService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PlayingRecordingUploadService { + + private final PlayingRepository playingRepository; + private final S3FileService s3FileService; + + @Transactional(readOnly = true) + public RecordingUploadUrlResponse createRecordingUploadUrl( + Long userId, Long playingId, RecordingUploadUrlRequest request + ) { + validatePlayingId(playingId); + + Playing playing = playingRepository.findByIdAndDeletedAtIsNull(playingId) + .orElseThrow(() -> new GeneralException(PlayingErrorStatus.PLAYING_NOT_FOUND)); + + playing.validatePlayingOwner(userId); + playing.validateInProgress(); + + return RecordingUploadUrlResponse.from(s3FileService.createPresignedUpload( + userId, S3FileType.RECORDING, request.toCommand()) + ); + } + + private void validatePlayingId(Long playingId) { + if (playingId == null || playingId < 1) { + throw new GeneralException(PlayingErrorStatus.INVALID_PLAYING_ID); + } + } +} diff --git a/src/main/java/com/mr/domain/playing/service/PlayingService.java b/src/main/java/com/mr/domain/playing/service/PlayingService.java index c804d8c4..ac47ae66 100644 --- a/src/main/java/com/mr/domain/playing/service/PlayingService.java +++ b/src/main/java/com/mr/domain/playing/service/PlayingService.java @@ -2,16 +2,11 @@ import com.mr.domain.backingtrack.entity.BackingTrack; import com.mr.domain.backingtrack.repository.BackingTrackRepository; -import com.mr.domain.analysis.service.AnalysisBarCalculator; import com.mr.domain.playing.dto.req.MidiEventSaveRequest; import com.mr.domain.playing.dto.req.PlayingStartRequest; -import com.mr.domain.playing.dto.req.RecordingUploadUrlRequest; import com.mr.domain.playing.dto.res.MidiEventSaveResponse; -import com.mr.domain.playing.dto.res.AnalysisContextResponse; import com.mr.domain.playing.dto.res.PlayingDeleteResponse; -import com.mr.domain.playing.dto.res.PlayingDetailResponse; import com.mr.domain.playing.dto.res.PlayingStartResponse; -import com.mr.domain.playing.dto.res.RecordingUploadUrlResponse; import com.mr.domain.playing.entity.MidiEventData; import com.mr.domain.playing.entity.Playing; import com.mr.domain.playing.exception.PlayingErrorStatus; @@ -43,7 +38,6 @@ public class PlayingService { private final PlayingRepository playingRepository; - private final AnalysisBarCalculator analysisBarCalculator; private final UserRepository userRepository; private final BackingTrackRepository backingTrackRepository; private final S3FileService s3FileService; @@ -91,25 +85,6 @@ public PlayingStartResponse startPlaying( } - @Transactional(readOnly = true) - public RecordingUploadUrlResponse createRecordingUploadUrl( - Long userId, Long playingId, RecordingUploadUrlRequest request - ) { - validatePlayingId(playingId); - - Playing playing = playingRepository.findByIdAndDeletedAtIsNull(playingId) - .orElseThrow(() -> new GeneralException(PlayingErrorStatus.PLAYING_NOT_FOUND)); - - playing.validatePlayingOwner(userId); - playing.validateInProgress(); - - return RecordingUploadUrlResponse.from(s3FileService.createPresignedUpload( - userId, - S3FileType.RECORDING, - request.toCommand()) - ); - } - public MidiEventSaveResponse saveMidiEvents( Long userId, Long playingId, MidiEventSaveRequest request ) { @@ -155,65 +130,6 @@ public MidiEventSaveResponse saveMidiEvents( }); } - @Transactional(readOnly = true) - public PlayingDetailResponse getPlayingDetail(Long userId, Long playingId) { - validatePlayingId(playingId); - - Playing playing = playingRepository.findByIdWithBackingTrack(playingId) - .orElseThrow(() -> new GeneralException(PlayingErrorStatus.PLAYING_NOT_FOUND)); - - playing.validatePlayingOwner(userId); - playing.validateCompleted(); - - String recordingFileUrl = - s3FileService.createPresignedDownload( - userId, - S3FileType.RECORDING, - playing.getRecordingObjectKey() - ); - - return PlayingDetailResponse.from(playing, recordingFileUrl); - } - - @Transactional(readOnly = true) - public AnalysisContextResponse getAnalysisContext(Long userId, Long playingId) { - validatePlayingId(playingId); - - Playing playing = playingRepository.findByIdWithBackingTrack(playingId) - .orElseThrow(() -> new GeneralException(PlayingErrorStatus.PLAYING_NOT_FOUND)); - - playing.validatePlayingOwner(userId); - playing.validateCompleted(); - if (playing.getBackingTrack() == null) { - throw new GeneralException(PlayingErrorStatus.BACKING_TRACK_NOT_FOUND); - } - - String recordingFileUrl = - s3FileService.createPresignedDownload( - userId, - S3FileType.RECORDING, - playing.getRecordingObjectKey() - ); - - BackingTrack backingTrack = playing.getBackingTrack(); - - String backingTrackAudioFileUrl = null; - - if (backingTrack.getAudioObjectKey() != null - && !backingTrack.getAudioObjectKey().isBlank()) { - - backingTrackAudioFileUrl = - s3FileService.createPresignedDownload( - backingTrack.getUser().getUserId(), - S3FileType.BACKING_TRACK, - backingTrack.getAudioObjectKey() - ); - } - - int totalBars = analysisBarCalculator.calculate(playing).totalBars(); - return AnalysisContextResponse.from(playing, totalBars, recordingFileUrl, backingTrackAudioFileUrl); - } - @Transactional public PlayingDeleteResponse deletePlaying(Long userId, Long playingId) { diff --git a/src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java b/src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java index 9c0953f5..3fe8ea9d 100644 --- a/src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java +++ b/src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java @@ -2,16 +2,23 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.mr.domain.playing.dto.req.MidiEventSaveRequest; +import com.mr.domain.playing.dto.req.RecordingUploadUrlRequest; import com.mr.domain.playing.dto.res.MidiEventSaveResponse; import com.mr.domain.playing.dto.res.AnalysisContextResponse; +import com.mr.domain.playing.dto.res.RecordingUploadUrlResponse; import com.mr.domain.playing.entity.enums.MidiType; +import com.mr.domain.playing.service.PlayingRecordingUploadService; +import com.mr.domain.playing.service.PlayingQueryService; import com.mr.domain.playing.service.PlayingService; import com.mr.domain.user.entity.enums.UserRole; import com.mr.global.apipayload.handler.GlobalExceptionHandler; +import com.mr.global.file.s3.dto.PresignedUrlUpload; import com.mr.global.security.principal.CustomUserDetails; +import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.AfterEach; @@ -33,6 +40,7 @@ import static com.mr.domain.playing.constant.MidiEventConstants.MAX_MIDI_EVENT_COUNT; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; @@ -56,6 +64,12 @@ class PlayingControllerTest { @Mock private PlayingService playingService; + @Mock + private PlayingRecordingUploadService playingFileService; + + @Mock + private PlayingQueryService playingQueryService; + @BeforeEach void setUp() { objectMapper = Jackson2ObjectMapperBuilder.json() @@ -63,7 +77,7 @@ void setUp() { .build(); mockMvc = MockMvcBuilders - .standaloneSetup(new PlayingController(playingService)) + .standaloneSetup(new PlayingController(playingService, playingFileService, playingQueryService)) .setControllerAdvice(new GlobalExceptionHandler()) .setMessageConverters( new MappingJackson2HttpMessageConverter(objectMapper) @@ -173,7 +187,7 @@ void getAnalysisContextSuccess() throws Exception { null, 60 ); - given(playingService.getAnalysisContext(USER_ID, PLAYING_ID)) + given(playingQueryService.getAnalysisContext(USER_ID, PLAYING_ID)) .willReturn(response); mockMvc.perform(get( @@ -190,7 +204,7 @@ void getAnalysisContextSuccess() throws Exception { .value("https://example.com/backing.mp3")) .andExpect(jsonPath("$.data.totalBars").value(60)); - then(playingService).should() + then(playingQueryService).should() .getAnalysisContext(USER_ID, PLAYING_ID); } } @@ -392,4 +406,36 @@ private MidiEventSaveRequest createRequest() { ); } + + @Nested + @DisplayName("녹음 파일 업로드 URL 발급") + class CreateRecordingUploadUrl { + + @Test + @DisplayName("녹음 파일 업로드 URL 발급 요청을 PlayingFileService에 위임한다") + void createRecordingUploadUrlSuccess() throws Exception { + RecordingUploadUrlRequest request = + new RecordingUploadUrlRequest("recording.mp3", "audio/mpeg", 1_024L); + + PresignedUrlUpload presignedUpload = + new PresignedUrlUpload( + "recordings/1/recording.mp3", + "https://example.com/presigned-upload-url", + Instant.now().plusSeconds(600), + Map.of("Content-Type", "audio/mpeg") + ); + + RecordingUploadUrlResponse response = RecordingUploadUrlResponse.from(presignedUpload); + + given(playingFileService.createRecordingUploadUrl(eq(USER_ID), eq(PLAYING_ID), any(RecordingUploadUrlRequest.class))) + .willReturn(response); + + mockMvc.perform(post("/api/playings/{playingId}/recording-upload-url", PLAYING_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + then(playingFileService).should().createRecordingUploadUrl(eq(USER_ID), eq(PLAYING_ID), any(RecordingUploadUrlRequest.class)); + } + } } diff --git a/src/test/java/com/mr/domain/playing/service/PlayingFileServiceTest.java b/src/test/java/com/mr/domain/playing/service/PlayingFileServiceTest.java new file mode 100644 index 00000000..3376cb5c --- /dev/null +++ b/src/test/java/com/mr/domain/playing/service/PlayingFileServiceTest.java @@ -0,0 +1,186 @@ +package com.mr.domain.playing.service; + +import com.mr.domain.playing.dto.req.RecordingUploadUrlRequest; +import com.mr.domain.playing.dto.res.RecordingUploadUrlResponse; +import com.mr.domain.playing.entity.Playing; +import com.mr.domain.playing.exception.PlayingErrorStatus; +import com.mr.domain.playing.repository.PlayingRepository; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.file.s3.dto.FileUploadCommand; +import com.mr.global.file.s3.dto.PresignedUrlUpload; +import com.mr.global.file.s3.enums.S3FileType; +import com.mr.global.file.s3.service.S3FileService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PlayingFileServiceTest { + + private static final String RECORDING_OBJECT_KEY = + "recordings/1/2026-08-02/150000_a1b2c3.mp3"; + + @Mock + private PlayingRepository playingRepository; + + @Mock + private S3FileService s3FileService; + + @Mock + private Playing playing; + + @InjectMocks + private PlayingRecordingUploadService playingFileService; + + private Long userId; + private Long playingId; + + @BeforeEach + void setUp() { + userId = 1L; + playingId = 1L; + } + + @Nested + @DisplayName("녹음 파일 업로드 URL 발급") + class CreateRecordingUploadUrl { + + @Test + @DisplayName("진행 중인 본인의 연주이면 녹음 파일 업로드 URL을 발급한다") + void createRecordingUploadUrl_success() { + // given + RecordingUploadUrlRequest request = + new RecordingUploadUrlRequest( + "recording.mp3", + "audio/mpeg", + 1_024L + ); + + FileUploadCommand command = + request.toCommand(); + + PresignedUrlUpload presignedUpload = + new PresignedUrlUpload( + RECORDING_OBJECT_KEY, + "https://example.com/presigned-upload-url", + Instant.now().plusSeconds(600), + Map.of("Content-Type", "audio/mpeg") + ); + + RecordingUploadUrlResponse expectedResponse = + RecordingUploadUrlResponse.from( + presignedUpload + ); + + when(playingRepository.findByIdAndDeletedAtIsNull(playingId)) + .thenReturn(Optional.of(playing)); + + when(s3FileService.createPresignedUpload( + userId, + S3FileType.RECORDING, + command + )).thenReturn(presignedUpload); + + // when + RecordingUploadUrlResponse response = + playingFileService.createRecordingUploadUrl( + userId, + playingId, + request + ); + + // then + assertThat(response) + .isEqualTo(expectedResponse); + + verify(playingRepository) + .findByIdAndDeletedAtIsNull(playingId); + + verify(playing) + .validatePlayingOwner(userId); + + verify(playing) + .validateInProgress(); + + verify(s3FileService) + .createPresignedUpload(userId, S3FileType.RECORDING, command); + } + + @Test + @DisplayName("진행 중이 아닌 연주에는 녹음 파일 업로드 URL을 발급하지 않는다") + void createRecordingUploadUrl_notInProgress() { + // given + RecordingUploadUrlRequest request = + new RecordingUploadUrlRequest( + "recording.mp3", + "audio/mpeg", + 1_024L + ); + + when(playingRepository.findByIdAndDeletedAtIsNull(playingId)) + .thenReturn(Optional.of(playing)); + + doThrow( + new GeneralException( + PlayingErrorStatus.INVALID_PLAYING_STATUS + ) + ) + .when(playing) + .validateInProgress(); + + // when & then + assertThatThrownBy(() -> + playingFileService.createRecordingUploadUrl( + userId, + playingId, + request + + ) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + PlayingErrorStatus.INVALID_PLAYING_STATUS + ); + }); + + verify(playingRepository) + .findByIdAndDeletedAtIsNull(playingId); + + verify(playing) + .validatePlayingOwner(userId); + + verify(playing) + .validateInProgress(); + + verify(s3FileService, never()) + .createPresignedUpload( + anyLong(), + any(S3FileType.class), + any(FileUploadCommand.class) + ); + } + } +} diff --git a/src/test/java/com/mr/domain/playing/service/PlayingQueryServiceTest.java b/src/test/java/com/mr/domain/playing/service/PlayingQueryServiceTest.java new file mode 100644 index 00000000..1e01f931 --- /dev/null +++ b/src/test/java/com/mr/domain/playing/service/PlayingQueryServiceTest.java @@ -0,0 +1,262 @@ +package com.mr.domain.playing.service; + +import com.mr.domain.analysis.service.AnalysisBarCalculator; +import com.mr.domain.backingtrack.entity.BackingTrack; +import com.mr.domain.playing.dto.res.AnalysisContextResponse; +import com.mr.domain.playing.dto.res.PlayingDetailResponse; +import com.mr.domain.playing.entity.Playing; +import com.mr.domain.playing.entity.enums.PlayingStatus; +import com.mr.domain.playing.exception.PlayingErrorStatus; +import com.mr.domain.playing.repository.PlayingRepository; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.file.s3.enums.S3FileType; +import com.mr.global.file.s3.service.S3FileService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PlayingQueryServiceTest { + + @Mock + private PlayingRepository playingRepository; + + @Mock + private AnalysisBarCalculator analysisBarCalculator; + + @Mock + private S3FileService s3FileService; + + @Mock + private Playing playing; + + @Mock + private BackingTrack backingTrack; + + @InjectMocks + private PlayingQueryService playingQueryService; + + private static final Integer BPM = 120; + + private static final String RECORDING_OBJECT_KEY = + "recordings/1/2026-08-02/150000_a1b2c3.mp3"; + + private static final String RECORDING_FILE_URL = + "https://example.com/presigned-recording.mp3"; + + private Long userId; + private Long playingId; + private Long backingTrackId; + + @BeforeEach + void setUp() { + userId = 1L; + playingId = 1L; + backingTrackId = 1L; + } + + @Nested + @DisplayName("연주 세션 단건 조회") + class GetPlayingDetail { + + @Test + @DisplayName("본인의 완료된 연주 세션을 조회한다") + void getPlayingDetailSuccess() { + + when(playingRepository.findByIdWithBackingTrack(playingId)) + .thenReturn(Optional.of(playing)); + + when(playing.getId()).thenReturn(playingId); + when(playing.getStatus()).thenReturn(PlayingStatus.COMPLETED); + when(playing.getRecordingObjectKey()).thenReturn(RECORDING_OBJECT_KEY); + when(s3FileService.createPresignedDownload(userId, S3FileType.RECORDING, RECORDING_OBJECT_KEY)).thenReturn(RECORDING_FILE_URL); + + PlayingDetailResponse response = + playingQueryService.getPlayingDetail(userId, playingId); + + assertThat(response.playingId()).isEqualTo(playingId); + assertThat(response.status()).isEqualTo(PlayingStatus.COMPLETED); + assertThat(response.recordingFileUrl()).isEqualTo(RECORDING_FILE_URL); + + verify(playingRepository).findByIdWithBackingTrack(playingId); + verify(playing).validatePlayingOwner(userId); + verify(playing).validateCompleted(); + verify(s3FileService).createPresignedDownload(userId, S3FileType.RECORDING, RECORDING_OBJECT_KEY); + } + + @Test + @DisplayName("연주 세션 ID가 1 미만이면 예외가 발생한다") + void invalidPlayingId() { + assertThatThrownBy(() -> + playingQueryService.getPlayingDetail(1L, 0L) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo(PlayingErrorStatus.INVALID_PLAYING_ID); + }); + } + + @Test + @DisplayName("연주 세션이 존재하지 않으면 예외가 발생한다") + void playingNotFound() { + // given + Long playingId = 10L; + + given(playingRepository.findByIdWithBackingTrack(playingId)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> + playingQueryService.getPlayingDetail(1L, playingId) + ) + .isInstanceOf(GeneralException.class); + } + + @Test + @DisplayName("다른 사용자의 연주 세션이면 예외가 발생한다") + void playingAccessDenied() { + + when(playingRepository.findByIdWithBackingTrack(playingId)) + .thenReturn(Optional.of(playing)); + + doThrow(new GeneralException( + PlayingErrorStatus.PLAYING_ACCESS_DENIED)) + .when(playing) + .validatePlayingOwner(userId); + + assertThatThrownBy(() -> + playingQueryService.getPlayingDetail(userId, playingId)) + .isInstanceOf(GeneralException.class); + + verify(playing, never()).validateCompleted(); + } + + @Test + @DisplayName("완료되지 않은 연주 세션이면 예외가 발생한다") + void playingNotCompleted() { + + when(playingRepository.findByIdWithBackingTrack(playingId)) + .thenReturn(Optional.of(playing)); + + doThrow(new GeneralException( + PlayingErrorStatus.PLAYING_NOT_COMPLETED)) + .when(playing) + .validateCompleted(); + + assertThatThrownBy(() -> + playingQueryService.getPlayingDetail(userId, playingId)) + .isInstanceOf(GeneralException.class); + } + } + + @Nested + @DisplayName("분석 마디 선택 정보 조회") + class GetAnalysisContext { + + @Test + @DisplayName("본인의 완료된 연주와 전체 마디 수를 조회한다") + void getAnalysisContextSuccess() { + when(playingRepository.findByIdWithBackingTrack(playingId)) + .thenReturn(Optional.of(playing)); + when(playing.getBackingTrack()).thenReturn(backingTrack); + when(playing.getId()).thenReturn(playingId); + when(backingTrack.getId()).thenReturn(backingTrackId); + when(playing.getBpm()).thenReturn(BPM); + when(playing.getMidiData()).thenReturn(List.of()); + when(backingTrack.getTimeSignature()).thenReturn("4/4"); + when(analysisBarCalculator.calculate(playing)) + .thenReturn(new AnalysisBarCalculator.BarMetrics( + new int[]{4, 4}, + 2_000D, + 60 + )); + + AnalysisContextResponse response = + playingQueryService.getAnalysisContext(userId, playingId); + + assertThat(response.playingId()).isEqualTo(playingId); + assertThat(response.backingTrackId()).isEqualTo(backingTrackId); + assertThat(response.totalBars()).isEqualTo(60); + verify(playing).validatePlayingOwner(userId); + verify(playing).validateCompleted(); + } + + @Test + @DisplayName("백킹트랙이 연결되지 않으면 예외가 발생한다") + void backingTrackNotFound() { + when(playingRepository.findByIdWithBackingTrack(playingId)) + .thenReturn(Optional.of(playing)); + when(playing.getBackingTrack()).thenReturn(null); + + assertThatThrownBy(() -> + playingQueryService.getAnalysisContext(userId, playingId) + ) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue( + "code", + PlayingErrorStatus.BACKING_TRACK_NOT_FOUND + ); + + verify(analysisBarCalculator, never()).calculate(any()); + } + + @Test + @DisplayName("완료되지 않은 연주는 분석 정보를 조회할 수 없다") + void playingNotCompleted() { + when(playingRepository.findByIdWithBackingTrack(playingId)) + .thenReturn(Optional.of(playing)); + doThrow(new GeneralException(PlayingErrorStatus.PLAYING_NOT_COMPLETED)) + .when(playing) + .validateCompleted(); + + assertThatThrownBy(() -> + playingQueryService.getAnalysisContext(userId, playingId) + ) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue( + "code", + PlayingErrorStatus.PLAYING_NOT_COMPLETED + ); + + verify(analysisBarCalculator, never()).calculate(any()); + } + + @Test + @DisplayName("다른 사용자의 연주는 조회할 수 없다") + void playingAccessDenied() { + when(playingRepository.findByIdWithBackingTrack(playingId)) + .thenReturn(Optional.of(playing)); + doThrow(new GeneralException(PlayingErrorStatus.PLAYING_ACCESS_DENIED)) + .when(playing) + .validatePlayingOwner(userId); + + assertThatThrownBy(() -> + playingQueryService.getAnalysisContext(userId, playingId) + ).isInstanceOf(GeneralException.class); + + verify(playing, never()).validateCompleted(); + verify(analysisBarCalculator, never()).calculate(any()); + } + } +} diff --git a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java index d7049152..9b1e7b93 100644 --- a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java +++ b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java @@ -1,18 +1,13 @@ package com.mr.domain.playing.service; import com.mr.domain.backingtrack.entity.BackingTrack; -import com.mr.domain.analysis.service.AnalysisBarCalculator; import com.mr.domain.backingtrack.entity.enums.AccessLevel; import com.mr.domain.backingtrack.repository.BackingTrackRepository; import com.mr.domain.playing.dto.req.MidiEventSaveRequest; import com.mr.domain.playing.dto.req.PlayingStartRequest; -import com.mr.domain.playing.dto.req.RecordingUploadUrlRequest; import com.mr.domain.playing.dto.res.MidiEventSaveResponse; -import com.mr.domain.playing.dto.res.AnalysisContextResponse; import com.mr.domain.playing.dto.res.PlayingDeleteResponse; -import com.mr.domain.playing.dto.res.PlayingDetailResponse; import com.mr.domain.playing.dto.res.PlayingStartResponse; -import com.mr.domain.playing.dto.res.RecordingUploadUrlResponse; import com.mr.domain.playing.entity.MidiEventData; import com.mr.domain.playing.entity.Playing; import com.mr.domain.playing.entity.enums.MidiType; @@ -25,9 +20,7 @@ import com.mr.domain.user.repository.UserRepository; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.event.PlayingCompletedEvent; -import com.mr.global.file.s3.dto.FileUploadCommand; import com.mr.global.file.s3.dto.ValidatedFile; -import com.mr.global.file.s3.dto.PresignedUrlUpload; import com.mr.global.file.s3.enums.S3FileType; import com.mr.global.file.s3.service.S3FileService; import org.junit.jupiter.api.BeforeEach; @@ -51,7 +44,6 @@ import java.time.LocalDate; import java.time.ZoneId; import java.util.List; -import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -75,9 +67,6 @@ class PlayingServiceTest { private static final Integer BPM = 120; private static final String RECORDING_OBJECT_KEY = "recordings/1/2026-08-02/150000_a1b2c3.mp3"; - private static final String RECORDING_FILE_URL = - "https://test-bucket.s3.ap-northeast-2.amazonaws.com/" - + RECORDING_OBJECT_KEY; private static final String BACKING_TRACK_OBJECT_KEY = "backing-tracks/1/2026-08-02/150000_a1b2c3.mp3"; private static final String BACKING_TRACK_FILE_URL = @@ -86,9 +75,6 @@ class PlayingServiceTest { @Mock private PlayingRepository playingRepository; - @Mock - private AnalysisBarCalculator analysisBarCalculator; - @Mock private UserRepository userRepository; @@ -897,189 +883,6 @@ private MidiEventSaveRequest createRequest() { return new MidiEventSaveRequest(events, RECORDING_OBJECT_KEY); } - @Nested - @DisplayName("연주 세션 단건 조회") - class GetPlayingDetail { - - @Test - @DisplayName("본인의 완료된 연주 세션을 조회한다") - void getPlayingDetailSuccess() { - - when(playingRepository.findByIdWithBackingTrack(playingId)) - .thenReturn(Optional.of(playing)); - - when(playing.getId()).thenReturn(playingId); - when(playing.getStatus()).thenReturn(PlayingStatus.COMPLETED); - - PlayingDetailResponse response = - playingService.getPlayingDetail(userId, playingId); - - assertThat(response.playingId()).isEqualTo(playingId); - assertThat(response.status()).isEqualTo(PlayingStatus.COMPLETED); - - verify(playing).validatePlayingOwner(userId); - verify(playing).validateCompleted(); - } - - @Test - @DisplayName("연주 세션 ID가 1 미만이면 예외가 발생한다") - void invalidPlayingId() { - assertThatThrownBy(() -> - playingService.getPlayingDetail(1L, 0L) - ) - .isInstanceOf(GeneralException.class) - .satisfies(exception -> { - GeneralException generalException = - (GeneralException) exception; - - assertThat(generalException.getCode()) - .isEqualTo(PlayingErrorStatus.INVALID_PLAYING_ID); - }); - } - - @Test - @DisplayName("연주 세션이 존재하지 않으면 예외가 발생한다") - void playingNotFound() { - // given - Long playingId = 10L; - - given(playingRepository.findByIdWithBackingTrack(playingId)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> - playingService.getPlayingDetail(1L, playingId) - ) - .isInstanceOf(GeneralException.class); - } - - @Test - @DisplayName("다른 사용자의 연주 세션이면 예외가 발생한다") - void playingAccessDenied() { - - when(playingRepository.findByIdWithBackingTrack(playingId)) - .thenReturn(Optional.of(playing)); - - doThrow(new GeneralException( - PlayingErrorStatus.PLAYING_ACCESS_DENIED)) - .when(playing) - .validatePlayingOwner(userId); - - assertThatThrownBy(() -> - playingService.getPlayingDetail(userId, playingId)) - .isInstanceOf(GeneralException.class); - - verify(playing, never()).validateCompleted(); - } - - @Test - @DisplayName("완료되지 않은 연주 세션이면 예외가 발생한다") - void playingNotCompleted() { - - when(playingRepository.findByIdWithBackingTrack(playingId)) - .thenReturn(Optional.of(playing)); - - doThrow(new GeneralException( - PlayingErrorStatus.PLAYING_NOT_COMPLETED)) - .when(playing) - .validateCompleted(); - - assertThatThrownBy(() -> - playingService.getPlayingDetail(userId, playingId)) - .isInstanceOf(GeneralException.class); - } - } - - @Nested - @DisplayName("분석 마디 선택 정보 조회") - class GetAnalysisContext { - - @Test - @DisplayName("본인의 완료된 연주와 전체 마디 수를 조회한다") - void getAnalysisContextSuccess() { - when(playingRepository.findByIdWithBackingTrack(playingId)) - .thenReturn(Optional.of(playing)); - when(playing.getBackingTrack()).thenReturn(backingTrack); - when(playing.getId()).thenReturn(playingId); - when(backingTrack.getId()).thenReturn(backingTrackId); - when(playing.getBpm()).thenReturn(BPM); - when(playing.getMidiData()).thenReturn(List.of()); - when(backingTrack.getTimeSignature()).thenReturn("4/4"); - when(analysisBarCalculator.calculate(playing)) - .thenReturn(new AnalysisBarCalculator.BarMetrics( - new int[]{4, 4}, - 2_000D, - 60 - )); - - AnalysisContextResponse response = - playingService.getAnalysisContext(userId, playingId); - - assertThat(response.playingId()).isEqualTo(playingId); - assertThat(response.backingTrackId()).isEqualTo(backingTrackId); - assertThat(response.totalBars()).isEqualTo(60); - verify(playing).validatePlayingOwner(userId); - verify(playing).validateCompleted(); - } - - @Test - @DisplayName("백킹트랙이 연결되지 않으면 예외가 발생한다") - void backingTrackNotFound() { - when(playingRepository.findByIdWithBackingTrack(playingId)) - .thenReturn(Optional.of(playing)); - when(playing.getBackingTrack()).thenReturn(null); - - assertThatThrownBy(() -> - playingService.getAnalysisContext(userId, playingId) - ) - .isInstanceOf(GeneralException.class) - .hasFieldOrPropertyWithValue( - "code", - PlayingErrorStatus.BACKING_TRACK_NOT_FOUND - ); - - verify(analysisBarCalculator, never()).calculate(any()); - } - - @Test - @DisplayName("완료되지 않은 연주는 분석 정보를 조회할 수 없다") - void playingNotCompleted() { - when(playingRepository.findByIdWithBackingTrack(playingId)) - .thenReturn(Optional.of(playing)); - doThrow(new GeneralException(PlayingErrorStatus.PLAYING_NOT_COMPLETED)) - .when(playing) - .validateCompleted(); - - assertThatThrownBy(() -> - playingService.getAnalysisContext(userId, playingId) - ) - .isInstanceOf(GeneralException.class) - .hasFieldOrPropertyWithValue( - "code", - PlayingErrorStatus.PLAYING_NOT_COMPLETED - ); - - verify(analysisBarCalculator, never()).calculate(any()); - } - - @Test - @DisplayName("다른 사용자의 연주는 조회할 수 없다") - void playingAccessDenied() { - when(playingRepository.findByIdWithBackingTrack(playingId)) - .thenReturn(Optional.of(playing)); - doThrow(new GeneralException(PlayingErrorStatus.PLAYING_ACCESS_DENIED)) - .when(playing) - .validatePlayingOwner(userId); - - assertThatThrownBy(() -> - playingService.getAnalysisContext(userId, playingId) - ).isInstanceOf(GeneralException.class); - - verify(playing, never()).validateCompleted(); - verify(analysisBarCalculator, never()).calculate(any()); - } - } - @Nested @DisplayName("연주 기록 삭제") class DeletePlaying { @@ -1212,129 +1015,4 @@ void deletePlayingInvalidId() { .findByIdAndDeletedAtIsNull(any()); } } - - @Nested - @DisplayName("녹음 파일 업로드 URL 발급") - class CreateRecordingUploadUrl { - - @Test - @DisplayName("진행 중인 본인의 연주이면 녹음 파일 업로드 URL을 발급한다") - void createRecordingUploadUrl_success() { - // given - RecordingUploadUrlRequest request = - new RecordingUploadUrlRequest( - "recording.mp3", - "audio/mpeg", - 1_024L - ); - - FileUploadCommand command = - request.toCommand(); - - PresignedUrlUpload presignedUpload = - new PresignedUrlUpload( - RECORDING_OBJECT_KEY, - "https://example.com/presigned-upload-url", - Instant.now().plusSeconds(600), - Map.of("Content-Type", "audio/mpeg") - ); - - RecordingUploadUrlResponse expectedResponse = - RecordingUploadUrlResponse.from( - presignedUpload - ); - - when(playingRepository.findByIdAndDeletedAtIsNull(playingId)) - .thenReturn(Optional.of(playing)); - - when(s3FileService.createPresignedUpload( - userId, - S3FileType.RECORDING, - command - )).thenReturn(presignedUpload); - - // when - RecordingUploadUrlResponse response = - playingService.createRecordingUploadUrl( - userId, - playingId, - request - ); - - // then - assertThat(response) - .isEqualTo(expectedResponse); - - verify(playingRepository) - .findByIdAndDeletedAtIsNull(playingId); - - verify(playing) - .validatePlayingOwner(userId); - - verify(playing) - .validateInProgress(); - - verify(s3FileService) - .createPresignedUpload(userId, S3FileType.RECORDING, command); - } - - @Test - @DisplayName("진행 중이 아닌 연주에는 녹음 파일 업로드 URL을 발급하지 않는다") - void createRecordingUploadUrl_notInProgress() { - // given - RecordingUploadUrlRequest request = - new RecordingUploadUrlRequest( - "recording.mp3", - "audio/mpeg", - 1_024L - ); - - when(playingRepository.findByIdAndDeletedAtIsNull(playingId)) - .thenReturn(Optional.of(playing)); - - doThrow( - new GeneralException( - PlayingErrorStatus.INVALID_PLAYING_STATUS - ) - ) - .when(playing) - .validateInProgress(); - - // when & then - assertThatThrownBy(() -> - playingService.createRecordingUploadUrl( - userId, - playingId, - request - - ) - ) - .isInstanceOf(GeneralException.class) - .satisfies(exception -> { - GeneralException generalException = - (GeneralException) exception; - - assertThat(generalException.getCode()) - .isEqualTo( - PlayingErrorStatus.INVALID_PLAYING_STATUS - ); - }); - - verify(playingRepository) - .findByIdAndDeletedAtIsNull(playingId); - - verify(playing) - .validatePlayingOwner(userId); - - verify(playing) - .validateInProgress(); - - verify(s3FileService, never()) - .createPresignedUpload( - anyLong(), - any(S3FileType.class), - any(FileUploadCommand.class) - ); - } - } }