From 59af61e4bb5c9c7fc03bbc1678726363de456db6 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 31 Jul 2026 00:45:50 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EC=97=B0=EC=A3=BC=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=9A=94=EC=B2=AD/=EC=9D=91=EB=8B=B5=20DTO=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playing/dto/req/PlayingStartRequest.java | 12 +++ .../playing/dto/res/PlayingStartResponse.java | 74 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/main/java/com/mr/domain/playing/dto/req/PlayingStartRequest.java create mode 100644 src/main/java/com/mr/domain/playing/dto/res/PlayingStartResponse.java diff --git a/src/main/java/com/mr/domain/playing/dto/req/PlayingStartRequest.java b/src/main/java/com/mr/domain/playing/dto/req/PlayingStartRequest.java new file mode 100644 index 00000000..7dc22dc5 --- /dev/null +++ b/src/main/java/com/mr/domain/playing/dto/req/PlayingStartRequest.java @@ -0,0 +1,12 @@ +package com.mr.domain.playing.dto.req; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record PlayingStartRequest( + + @NotNull(message = "백킹트랙 ID는 필수입니다.") + @Min(value = 1, message = "백킹트랙 ID는 1 이상이어야 합니다.") + Long backingTrackId +) { +} diff --git a/src/main/java/com/mr/domain/playing/dto/res/PlayingStartResponse.java b/src/main/java/com/mr/domain/playing/dto/res/PlayingStartResponse.java new file mode 100644 index 00000000..492f25c8 --- /dev/null +++ b/src/main/java/com/mr/domain/playing/dto/res/PlayingStartResponse.java @@ -0,0 +1,74 @@ +package com.mr.domain.playing.dto.res; + +import com.mr.domain.backingTrack.entity.BackingTrack; +import com.mr.domain.backingTrack.entity.ChordProgression; +import com.mr.domain.backingTrack.entity.enums.ScaleType; +import com.mr.domain.playing.entity.Playing; +import com.mr.domain.playing.entity.enums.PlayingStatus; + +import java.time.LocalDateTime; +import java.util.List; + +public record PlayingStartResponse( + Long playingId, + PlayingStatus status, + BackingTrackResponse backingTrack, + LocalDateTime startedAt +) { + + public static PlayingStartResponse from (Playing playing) { + return new PlayingStartResponse( + playing.getId(), + playing.getStatus(), + BackingTrackResponse.from(playing.getBackingTrack()), + playing.getStartedAt() + ); + } + + public record BackingTrackResponse( + Long backingTrackId, + String title, + String audioFileUrl, + String genre, + String keySignature, + ScaleType scaleType, + Integer bpm, + String timeSignature, + Integer playtimeSec, + List chordProgression + + ) { + public static BackingTrackResponse from(BackingTrack backingTrack) { + return new BackingTrackResponse( + backingTrack.getId(), + backingTrack.getTitle(), + backingTrack.getAudioFileUrl(), + backingTrack.getGenre(), + backingTrack.getKeySignature(), + backingTrack.getScaleType(), + backingTrack.getBpm(), + backingTrack.getTimeSignature(), + backingTrack.getPlaytimeSec(), + backingTrack.getChordProgressions() + .stream() + .map(ChordProgressionResponse::from) + .toList() + ); + } + } + + public record ChordProgressionResponse( + Integer measureNo, + Integer sequenceNo, + String chordName + ) { + public static ChordProgressionResponse from(ChordProgression chordProgression) { + + return new ChordProgressionResponse( + chordProgression.getMeasureNo(), + chordProgression.getSequenceNo(), + chordProgression.getChordName() + ); + } + } +} From 56795b5bdb969dfb1ede27d4a73a310d4c268ffc Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 31 Jul 2026 00:46:45 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EB=B0=B1=ED=82=B9=ED=8A=B8?= =?UTF-8?q?=EB=9E=99=20=EA=B8=B0=EB=B0=98=20=EC=97=B0=EC=A3=BC=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20API=20=EA=B5=AC=ED=98=84=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playing/controller/PlayingController.java | 17 ++++++ .../playing/exception/PlayingErrorStatus.java | 3 ++ .../playing/service/PlayingService.java | 52 +++++++++++++++++++ 3 files changed, 72 insertions(+) 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 80169b39..d8236845 100644 --- a/src/main/java/com/mr/domain/playing/controller/PlayingController.java +++ b/src/main/java/com/mr/domain/playing/controller/PlayingController.java @@ -1,7 +1,9 @@ package com.mr.domain.playing.controller; import com.mr.domain.playing.dto.req.MidiEventSaveRequest; +import com.mr.domain.playing.dto.req.PlayingStartRequest; import com.mr.domain.playing.dto.res.MidiEventSaveResponse; +import com.mr.domain.playing.dto.res.PlayingStartResponse; import com.mr.domain.playing.service.PlayingService; import com.mr.global.apipayload.ApiResponse; import com.mr.global.security.principal.CustomUserDetails; @@ -22,6 +24,21 @@ public class PlayingController { private final PlayingService playingService; + @Operation( + summary = "연주 세션 시작", + description = "백킹트랙 기반 연주 세션을 생성하고 연주 시작 상태로 변경합니다." + ) + @PostMapping + public ApiResponse startPlaying( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody PlayingStartRequest request + ){ + Long userId = userDetails.getUserId(); + + PlayingStartResponse response = playingService.startPlaying(userId, request); + return ApiResponse.onSuccess(response); + } + @Operation( summary = "MIDI 이벤트 저장", description = "연주 세션에 대한 MIDI 이벤트를 저장하고 연주를 완료 상태로 변경합니다." diff --git a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java index c5379fb1..349ed352 100644 --- a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java +++ b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java @@ -19,7 +19,10 @@ public enum PlayingErrorStatus implements BaseCode { MISSING_PLAYING_STATUS(HttpStatus.BAD_REQUEST, "PLAYING_400_12", "연주 상태는 필수 입력값입니다."), UNSUPPORTED_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_13", "현재 지원하지 않는 연주 모드입니다."), PLAYING_ACCESS_DENIED(HttpStatus.FORBIDDEN, "PLAYING_403_01", "해당 연주에 대한 접근 권한이 없습니다."), + BACKING_TRACK_ACCESS_FORBIDDEN(HttpStatus.FORBIDDEN, "PLAYING_403_02", "해당 백킹트랙으로 연주를 시작할 수 없습니다."), PLAYING_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAYING_404_01", "연주 세션을 찾을 수 없습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAYING_404_02", "유저를 찾을 수 없습니다."), + BACKING_TRACK_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAYING_404_03", "백킹트랙을 찾을 수 없습니다."), INVALID_PLAYING_STATUS(HttpStatus.CONFLICT, "PLAYING_409_01", "현재 연주 상태에서는 요청한 작업을 수행할 수 없습니다."), MISSING_PLAYING_START_TIME(HttpStatus.CONFLICT, "PLAYING_409_02", "연주 시작 시간이 기록되지 않았습니다."), INVALID_PLAYING_DURATION(HttpStatus.CONFLICT, "PLAYING_409_03", "연주 종료 시간이 시작 시간보다 이전일 수 없습니다."), 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 cf0afb8e..eb730275 100644 --- a/src/main/java/com/mr/domain/playing/service/PlayingService.java +++ b/src/main/java/com/mr/domain/playing/service/PlayingService.java @@ -1,13 +1,19 @@ package com.mr.domain.playing.service; +import com.mr.domain.backingTrack.entity.BackingTrack; +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.res.MidiEventSaveResponse; +import com.mr.domain.playing.dto.res.PlayingStartResponse; import com.mr.domain.playing.entity.MidiEventData; import com.mr.domain.playing.entity.Playing; import com.mr.domain.playing.entity.enums.PlayingStatus; import com.mr.domain.playing.exception.MidiEventErrorStatus; import com.mr.domain.playing.exception.PlayingErrorStatus; import com.mr.domain.playing.repository.PlayingRepository; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.repository.UserRepository; import com.mr.global.apipayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -16,6 +22,8 @@ import java.time.LocalDateTime; import java.util.List; +import static com.mr.domain.backingTrack.entity.enums.AccessLevel.PUBLIC; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -23,6 +31,34 @@ public class PlayingService { private static final long MIDI_SAVE_REQUEST_INTERVAL_MINUTES = 1L; private final PlayingRepository playingRepository; + private final UserRepository userRepository; + private final BackingTrackRepository backingTrackRepository; + + @Transactional + public PlayingStartResponse startPlaying( + Long userId, PlayingStartRequest request + ) { + validateUserId(userId); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(PlayingErrorStatus.USER_NOT_FOUND)); + + BackingTrack backingTrack = backingTrackRepository.findById(request.backingTrackId()) + .orElseThrow(() -> new GeneralException(PlayingErrorStatus.BACKING_TRACK_NOT_FOUND)); + + validateBackingTrackAccessible(userId, backingTrack); + + Playing playing = Playing.createBackingTrack( + user, backingTrack, backingTrack.getBpm() + ); + + playing.start(); + Playing savedPlaying = playingRepository.save(playing); + + return PlayingStartResponse.from(savedPlaying); + + + } @Transactional public MidiEventSaveResponse saveMidiEvents( @@ -54,12 +90,28 @@ public MidiEventSaveResponse saveMidiEvents( ); } + private void validateUserId(Long userId) { + if (userId == null || userId < 1) { + throw new GeneralException(PlayingErrorStatus.MISSING_USER_ID); + } + } + private void validatePlayingId(Long playingId) { if (playingId == null || playingId < 1 ) { throw new GeneralException(MidiEventErrorStatus.INVALID_PLAYING_ID); } } + private void validateBackingTrackAccessible(Long userId, BackingTrack backingTrack) { + if (backingTrack.getAccessLevel() == PUBLIC){ + return; + } + + if (backingTrack.getUser() == null || !backingTrack.getUser().getUserId().equals(userId)){ + throw new GeneralException(PlayingErrorStatus.BACKING_TRACK_ACCESS_FORBIDDEN); + } + } + private void validateMidiSaveRequestInterval(Long userId) { LocalDateTime oneMinuteAgo = LocalDateTime.now() From 98234e983c9453c0ae131c7aed28bf6f8730525a Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 31 Jul 2026 00:48:57 +0900 Subject: [PATCH 3/9] =?UTF-8?q?test:=20=EC=97=B0=EC=A3=BC=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/playing/entity/PlayingTest.java | 97 +++++++ .../playing/service/PlayingServiceTest.java | 263 +++++++++++++++++- 2 files changed, 356 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/mr/domain/playing/entity/PlayingTest.java b/src/test/java/com/mr/domain/playing/entity/PlayingTest.java index f82df7bb..f355aeab 100644 --- a/src/test/java/com/mr/domain/playing/entity/PlayingTest.java +++ b/src/test/java/com/mr/domain/playing/entity/PlayingTest.java @@ -2,6 +2,7 @@ import com.mr.domain.backingTrack.entity.BackingTrack; import com.mr.domain.playing.entity.enums.MidiType; +import com.mr.domain.playing.entity.enums.PlayingMode; import com.mr.domain.playing.entity.enums.PlayingStatus; import com.mr.domain.playing.exception.MidiEventErrorStatus; import com.mr.domain.playing.exception.PlayingErrorStatus; @@ -10,6 +11,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -563,6 +565,101 @@ void rejectNullBackingTrack() { }); } + @Test + @DisplayName("READY 상태의 연주를 시작하면 IN_PROGRESS 상태가 되고 시작 시간이 기록된다") + void startPlaying_success() { + // given + User user = mock(User.class); + BackingTrack backingTrack = mock(BackingTrack.class); + + Playing playing = Playing.createBackingTrack( + user, + backingTrack, + 120 + ); + + LocalDateTime beforeStart = LocalDateTime.now(); + + // when + playing.start(); + + LocalDateTime afterStart = LocalDateTime.now(); + + // then + assertThat(playing.getStatus()) + .isEqualTo(PlayingStatus.IN_PROGRESS); + + assertThat(playing.getStartedAt()) + .isNotNull() + .isBetween(beforeStart, afterStart); + } + + @Test + @DisplayName("IN_PROGRESS 상태의 연주를 다시 시작하면 예외가 발생한다") + void startPlaying_invalidStatus() { + // given + User user = mock(User.class); + BackingTrack backingTrack = mock(BackingTrack.class); + + Playing playing = Playing.createBackingTrack( + user, + backingTrack, + 120 + ); + + playing.start(); + + // when & then + assertThatThrownBy(playing::start) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + PlayingErrorStatus.INVALID_PLAYING_STATUS + ); + }); + } + + @Test + @DisplayName("백킹트랙 연주를 생성하면 READY 상태와 비공개 설정이 적용된다") + void createBackingTrackPlaying_success() { + // given + User user = mock(User.class); + BackingTrack backingTrack = mock(BackingTrack.class); + + // when + Playing playing = Playing.createBackingTrack( + user, + backingTrack, + 120 + ); + + // then + assertThat(playing.getUser()) + .isEqualTo(user); + + assertThat(playing.getBackingTrack()) + .isEqualTo(backingTrack); + + assertThat(playing.getMode()) + .isEqualTo(PlayingMode.BACKING_TRACK); + + assertThat(playing.getStatus()) + .isEqualTo(PlayingStatus.READY); + + assertThat(playing.getBpm()) + .isEqualTo(120); + + assertThat(playing.isPublic()) + .isFalse(); + + assertThat(playing.getStartedAt()) + .isNull(); + } + private Playing createReadyPlaying() { User user = mock(User.class); BackingTrack backingTrack = mock(BackingTrack.class); 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 8c4ff4fb..a1574243 100644 --- a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java +++ b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java @@ -1,23 +1,33 @@ package com.mr.domain.playing.service; +import com.mr.domain.backingTrack.entity.BackingTrack; +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.res.MidiEventSaveResponse; +import com.mr.domain.playing.dto.res.PlayingStartResponse; import com.mr.domain.playing.entity.MidiEventData; import com.mr.domain.playing.entity.Playing; import com.mr.domain.playing.entity.enums.MidiType; +import com.mr.domain.playing.entity.enums.PlayingMode; import com.mr.domain.playing.entity.enums.PlayingStatus; import com.mr.domain.playing.exception.MidiEventErrorStatus; import com.mr.domain.playing.exception.PlayingErrorStatus; import com.mr.domain.playing.repository.PlayingRepository; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.repository.UserRepository; import com.mr.global.apipayload.exception.GeneralException; 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.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.List; @@ -27,33 +37,50 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doThrow; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; - +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; @ExtendWith(MockitoExtension.class) class PlayingServiceTest { + private static final Integer BPM = 120; + @Mock private PlayingRepository playingRepository; + @Mock + private UserRepository userRepository; + + @Mock + private BackingTrackRepository backingTrackRepository; + @Mock private Playing playing; + private User user; + private BackingTrack backingTrack; + @InjectMocks private PlayingService playingService; private Long userId; private Long playingId; + private Long backingTrackId; @BeforeEach void setUp() { userId = 1L; playingId = 10L; + backingTrackId = 11L; + + user = mock(User.class); + backingTrack = mock(BackingTrack.class); } @Nested @@ -393,6 +420,234 @@ void saveMidiEvents_playingNotFound() { } } + @Nested + @DisplayName("연주 세션 시작") + class PlayingStart { + + @Test + @DisplayName("사용자와 백킹트랙이 존재하면 연주 세션을 시작한다") + void startPlaying_success() { + // given + PlayingStartRequest request = + new PlayingStartRequest(backingTrackId); + + given(userRepository.findById(userId)) + .willReturn(Optional.of(user)); + + given(backingTrackRepository.findById(backingTrackId)) + .willReturn(Optional.of(backingTrack)); + + given(backingTrack.getId()) + .willReturn(backingTrackId); + + given(backingTrack.getAccessLevel()) + .willReturn(AccessLevel.PUBLIC); + + given(backingTrack.getBpm()) + .willReturn(BPM); + + given(backingTrack.getTitle()) + .willReturn("테스트 백킹트랙"); + + given(backingTrack.getAudioFileUrl()) + .willReturn("https://example.com/backing-track.mp3"); + + given(backingTrack.getChordProgressions()) + .willReturn(List.of()); + + given(playingRepository.save(any(Playing.class))) + .willAnswer(invocation -> { + Playing playing = invocation.getArgument(0); + + ReflectionTestUtils.setField( + playing, + "id", + playingId + ); + + return playing; + }); + + // when + PlayingStartResponse response = + playingService.startPlaying(userId, request); + + // then + assertThat(response.playingId()) + .isEqualTo(playingId); + + assertThat(response.status()) + .isEqualTo(PlayingStatus.IN_PROGRESS); + + assertThat(response.startedAt()) + .isNotNull(); + + assertThat(response.backingTrack()) + .isNotNull(); + + assertThat(response.backingTrack().backingTrackId()) + .isEqualTo(backingTrackId); + + ArgumentCaptor playingCaptor = + ArgumentCaptor.forClass(Playing.class); + + verify(playingRepository) + .save(playingCaptor.capture()); + + Playing savedPlaying = playingCaptor.getValue(); + + assertThat(savedPlaying.getUser()) + .isSameAs(user); + + assertThat(savedPlaying.getBackingTrack()) + .isSameAs(backingTrack); + + assertThat(savedPlaying.getMode()) + .isEqualTo(PlayingMode.BACKING_TRACK); + + assertThat(savedPlaying.getStatus()) + .isEqualTo(PlayingStatus.IN_PROGRESS); + + assertThat(savedPlaying.getBpm()) + .isEqualTo(BPM); + + assertThat(savedPlaying.getStartedAt()) + .isNotNull(); + } + + @Test + @DisplayName("사용자가 존재하지 않으면 연주 세션을 생성하지 않는다") + void startPlaying_userNotFound() { + // given + PlayingStartRequest request = new PlayingStartRequest(backingTrackId); + given(userRepository.findById(userId)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> + playingService.startPlaying( + userId, + request + ) + ) + .isInstanceOf(GeneralException.class); + + verify(backingTrackRepository, never()) + .findById(any()); + + verify(playingRepository, never()) + .save(any()); + } + + @Test + @DisplayName("백킹트랙이 존재하지 않으면 연주 세션을 생성하지 않는다") + void startPlaying_backingTrackNotFound() { + // given + PlayingStartRequest request = new PlayingStartRequest(backingTrackId); + given(userRepository.findById(userId)) + .willReturn(Optional.of(user)); + + given(backingTrackRepository.findById(backingTrackId)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> + playingService.startPlaying( + userId, + request + ) + ) + .isInstanceOf(GeneralException.class); + + verify(playingRepository, never()) + .save(any()); + } + + @Test + @DisplayName("사용자 ID가 null이면 연주 세션을 생성하지 않는다") + void startPlaying_nullUserId() { + // given + PlayingStartRequest request = new PlayingStartRequest(backingTrackId); + + // when & then + assertThatThrownBy(() -> + playingService.startPlaying( + null, + request + ) + ) + .isInstanceOf(GeneralException.class); + + verify(userRepository, never()) + .findById(any()); + + verify(backingTrackRepository, never()) + .findById(any()); + + verify(playingRepository, never()) + .save(any()); + } + + @Test + @DisplayName("백킹트랙 ID가 null이면 연주 세션을 생성하지 않는다") + void startPlaying_nullBackingTrackId() { + // when & then + assertThatThrownBy(() -> + playingService.startPlaying( + userId, + null + ) + ) + .isInstanceOf(GeneralException.class); + + verify(playingRepository, never()) + .save(any()); + } + + private User mockUser() { + User user = mock(User.class); + + given(user.getUserId()) + .willReturn(userId); + + return user; + } + + private BackingTrack mockBackingTrack() { + BackingTrack backingTrack = + mock(BackingTrack.class); + + given(backingTrack.getId()) + .willReturn(backingTrackId); + + given(backingTrack.getTitle()) + .willReturn("Blues Backing Track"); + + given(backingTrack.getAudioFileUrl()) + .willReturn("https://example.com/backing-track.mp3"); + + given(backingTrack.getGenre()) + .willReturn("BLUES"); + + given(backingTrack.getKeySignature()) + .willReturn("C"); + + given(backingTrack.getBpm()) + .willReturn(BPM); + + given(backingTrack.getTimeSignature()) + .willReturn("4/4"); + + given(backingTrack.getPlaytimeSec()) + .willReturn(180); + + given(backingTrack.getChordProgressions()) + .willReturn(List.of()); + + return backingTrack; + } + } + private MidiEventSaveRequest createRequest() { List events = List.of( From 24d5fecb10b5cd787a8d15fd2a3548e51d3821f9 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 31 Jul 2026 01:14:15 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20MIDI=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=EC=9D=98=20null=20=EC=9A=94=EC=86=8C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/playing/dto/req/MidiEventSaveRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/playing/dto/req/MidiEventSaveRequest.java b/src/main/java/com/mr/domain/playing/dto/req/MidiEventSaveRequest.java index bfde1802..7972b48f 100644 --- a/src/main/java/com/mr/domain/playing/dto/req/MidiEventSaveRequest.java +++ b/src/main/java/com/mr/domain/playing/dto/req/MidiEventSaveRequest.java @@ -15,7 +15,7 @@ public record MidiEventSaveRequest ( @NotEmpty(message = "MIDI 이벤트 목록은 필수입니다") @Size(max = MAX_MIDI_EVENT_COUNT, message = "MIDI 이벤트는 최대 100,000개까지 저장할 수 있습니다.") - List<@Valid MidiEventRequest> events + List<@NotNull @Valid MidiEventRequest> events ) { public record MidiEventRequest ( From 0b41811670e30e5be57be8f20f5f90cc238c889a Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 31 Jul 2026 01:17:49 +0900 Subject: [PATCH 5/9] =?UTF-8?q?test:=20=EB=B0=B1=ED=82=B9=ED=8A=B8?= =?UTF-8?q?=EB=9E=99=20ID=20null=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=9D=98=20DTO=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?= =?UTF-8?q?=20=EC=A0=84=EB=8B=AC=20=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/playing/service/PlayingServiceTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 a1574243..fb23824a 100644 --- a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java +++ b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java @@ -591,11 +591,15 @@ void startPlaying_nullUserId() { @Test @DisplayName("백킹트랙 ID가 null이면 연주 세션을 생성하지 않는다") void startPlaying_nullBackingTrackId() { + + // given + PlayingStartRequest request = new PlayingStartRequest(null); // 백킹트랙 ID만 null인 DTO 생성 + // when & then assertThatThrownBy(() -> playingService.startPlaying( userId, - null + request ) ) .isInstanceOf(GeneralException.class); From aea9c86bc55a6c97fa090bc7c913c2151fcd46e3 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 31 Jul 2026 01:18:53 +0900 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20=ED=95=98=EB=93=9C=EC=BD=94?= =?UTF-8?q?=EB=94=A9=EB=90=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=EB=A5=BC?= =?UTF-8?q?=20=EC=83=81=EC=88=98=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/playing/controller/PlayingControllerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 02b32467..551e181e 100644 --- a/src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java +++ b/src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java @@ -71,7 +71,7 @@ void setUp() { CustomUserDetails userDetails = new CustomUserDetails( - 1L, + USER_ID, UserRole.ROLE_STUDENT ); From 4f54f4615e006b7dfdabdd2c16c94e2f566388ec Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 31 Jul 2026 21:12:22 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=EC=97=B0=EC=A3=BC=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=8B=9C=20=EC=86=8C=ED=94=84=ED=8A=B8=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EB=90=9C=20=EB=B0=B1=ED=82=B9=ED=8A=B8=EB=9E=99=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=A0=9C=EC=99=B8=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/playing/service/PlayingService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 eb730275..7bdc82d6 100644 --- a/src/main/java/com/mr/domain/playing/service/PlayingService.java +++ b/src/main/java/com/mr/domain/playing/service/PlayingService.java @@ -43,7 +43,7 @@ public PlayingStartResponse startPlaying( User user = userRepository.findById(userId) .orElseThrow(() -> new GeneralException(PlayingErrorStatus.USER_NOT_FOUND)); - BackingTrack backingTrack = backingTrackRepository.findById(request.backingTrackId()) + BackingTrack backingTrack = backingTrackRepository.findByIdAndDeletedAtIsNull(request.backingTrackId()) .orElseThrow(() -> new GeneralException(PlayingErrorStatus.BACKING_TRACK_NOT_FOUND)); validateBackingTrackAccessible(userId, backingTrack); From 87757205afea1656ad2ab5b12d6dafa64672cdb4 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Sat, 1 Aug 2026 00:00:39 +0900 Subject: [PATCH 8/9] =?UTF-8?q?docs:=20ACADEMY=20=EC=A0=91=EA=B7=BC=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20TODO=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mr/domain/playing/service/PlayingService.java | 5 +++++ 1 file changed, 5 insertions(+) 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 7bdc82d6..872effb9 100644 --- a/src/main/java/com/mr/domain/playing/service/PlayingService.java +++ b/src/main/java/com/mr/domain/playing/service/PlayingService.java @@ -107,6 +107,11 @@ private void validateBackingTrackAccessible(Long userId, BackingTrack backingTra return; } + // TODO: + // ACADEMY 접근 정책은 User-학원 관계가 추가되면 + // 같은 학원 사용자에게 접근을 허용하도록 수정한다. + // 현재는 PRIVATE와 동일하게 생성자만 접근 가능하도록 처리한다. + if (backingTrack.getUser() == null || !backingTrack.getUser().getUserId().equals(userId)){ throw new GeneralException(PlayingErrorStatus.BACKING_TRACK_ACCESS_FORBIDDEN); } From f1dc6d42a7e468e88827e7ab12c5c80e65a22729 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Sat, 1 Aug 2026 00:06:18 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20MIDI=20=EC=A0=80=EC=9E=A5=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=A0=9C=ED=95=9C=20=EC=A0=9C=EA=B1=B0=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/MidiEventErrorStatus.java | 1 - .../playing/repository/PlayingRepository.java | 6 --- .../playing/service/PlayingService.java | 25 ---------- .../playing/service/PlayingServiceTest.java | 49 ------------------- 4 files changed, 81 deletions(-) diff --git a/src/main/java/com/mr/domain/playing/exception/MidiEventErrorStatus.java b/src/main/java/com/mr/domain/playing/exception/MidiEventErrorStatus.java index 4a3a0b30..81bd30f0 100644 --- a/src/main/java/com/mr/domain/playing/exception/MidiEventErrorStatus.java +++ b/src/main/java/com/mr/domain/playing/exception/MidiEventErrorStatus.java @@ -21,7 +21,6 @@ public enum MidiEventErrorStatus implements BaseCode { DUPLICATE_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "MIDI_400_10", "동일한 시간에 중복된 MIDI sequence 값이 존재합니다."), PLAYING_NOT_IN_PROGRESS(HttpStatus.CONFLICT, "MIDI_409_01", "진행 중인 연주 세션에만 MIDI 이벤트를 저장할 수 있습니다."), - MIDI_SAVE_REQUEST_TOO_FREQUENT(HttpStatus.TOO_MANY_REQUESTS, "MIDI_429_01", "연주 완료 요청은 1분에 한 번만 가능합니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java b/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java index 590d44b1..2086e32b 100644 --- a/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java +++ b/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java @@ -87,10 +87,4 @@ interface PracticeTotals { } Optional findByIdAndDeletedAtIsNull(Long playingId); - - boolean existsByUser_UserIdAndStatusAndEndedAtAfterAndDeletedAtIsNull( - Long userId, - PlayingStatus status, - LocalDateTime endedAt - ); } 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 872effb9..853758c4 100644 --- a/src/main/java/com/mr/domain/playing/service/PlayingService.java +++ b/src/main/java/com/mr/domain/playing/service/PlayingService.java @@ -29,7 +29,6 @@ @Transactional(readOnly = true) public class PlayingService { - private static final long MIDI_SAVE_REQUEST_INTERVAL_MINUTES = 1L; private final PlayingRepository playingRepository; private final UserRepository userRepository; private final BackingTrackRepository backingTrackRepository; @@ -70,7 +69,6 @@ public MidiEventSaveResponse saveMidiEvents( .orElseThrow(() -> new GeneralException(PlayingErrorStatus.PLAYING_NOT_FOUND)); playing.validatePlayingOwner(userId); - validateMidiSaveRequestInterval(userId); List midiEvents = request.events() .stream() @@ -116,27 +114,4 @@ private void validateBackingTrackAccessible(Long userId, BackingTrack backingTra throw new GeneralException(PlayingErrorStatus.BACKING_TRACK_ACCESS_FORBIDDEN); } } - - private void validateMidiSaveRequestInterval(Long userId) { - LocalDateTime oneMinuteAgo = - LocalDateTime.now() - .minusMinutes( - MIDI_SAVE_REQUEST_INTERVAL_MINUTES - ); - - boolean recentlyCompleted = - playingRepository - .existsByUser_UserIdAndStatusAndEndedAtAfterAndDeletedAtIsNull( - userId, - PlayingStatus.COMPLETED, - oneMinuteAgo - ); - - if (recentlyCompleted) { - throw new GeneralException( - MidiEventErrorStatus - .MIDI_SAVE_REQUEST_TOO_FREQUENT - ); - } - } } 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 fb23824a..907d96e8 100644 --- a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java +++ b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java @@ -181,55 +181,6 @@ void saveMidiEvents_accessDenied() { .completeWithMidiData(anyList()); } - @Test - @DisplayName("동일 사용자가 1분 이내에 다시 완료 요청하면 예외가 발생한다") - void saveMidiEvents_requestedAgainWithinOneMinute() { - // given - MidiEventSaveRequest request = createRequest(); - - when( - playingRepository - .findByIdAndDeletedAtIsNull(playingId) - ) - .thenReturn(Optional.of(playing)); - - when( - playingRepository - .existsByUser_UserIdAndStatusAndEndedAtAfterAndDeletedAtIsNull( - eq(userId), - eq(PlayingStatus.COMPLETED), - any(LocalDateTime.class) - ) - ) - .thenReturn(true); - - // when & then - assertThatThrownBy(() -> - playingService.saveMidiEvents( - userId, - playingId, - request - ) - ) - .isInstanceOf(GeneralException.class) - .satisfies(exception -> { - GeneralException generalException = - (GeneralException) exception; - - assertThat(generalException.getCode()) - .isEqualTo( - MidiEventErrorStatus - .MIDI_SAVE_REQUEST_TOO_FREQUENT - ); - }); - - verify(playing) - .validatePlayingOwner(userId); - - verify(playing, never()) - .completeWithMidiData(anyList()); - } - @Test @DisplayName("요청 DTO의 MIDI 이벤트를 엔티티 값 객체로 변환한다") void saveMidiEvents_convertsRequestToEntity() {