From cc825f471e23d7b4ef211fa71b6f19953a83017c Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Wed, 29 Jul 2026 21:42:28 +0900 Subject: [PATCH 01/14] =?UTF-8?q?refactor:=20MIDI=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=98=88=EC=99=B8=20=EC=BD=94=EB=93=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/playing/entity/MidiEventData.java | 12 +++---- .../com/mr/domain/playing/entity/Playing.java | 19 ++++++++---- .../exception/MidiEventErrorStatus.java | 31 +++++++++++++++++++ .../playing/exception/PlayingErrorStatus.java | 16 +++------- 4 files changed, 54 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/mr/domain/playing/exception/MidiEventErrorStatus.java diff --git a/src/main/java/com/mr/domain/playing/entity/MidiEventData.java b/src/main/java/com/mr/domain/playing/entity/MidiEventData.java index 3a2af67c..ccd847ae 100644 --- a/src/main/java/com/mr/domain/playing/entity/MidiEventData.java +++ b/src/main/java/com/mr/domain/playing/entity/MidiEventData.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import com.mr.domain.playing.entity.enums.MidiType; -import com.mr.domain.playing.exception.PlayingErrorStatus; +import com.mr.domain.playing.exception.MidiEventErrorStatus; import com.mr.global.apipayload.exception.GeneralException; import lombok.Getter; @@ -54,31 +54,31 @@ public static MidiEventData of( private static void validateSequence(Integer sequence) { if (sequence == null || sequence < 0) { - throw new GeneralException(PlayingErrorStatus.INVALID_MIDI_SEQUENCE); + throw new GeneralException(MidiEventErrorStatus.INVALID_MIDI_SEQUENCE); } } private static void validateMidiType(MidiType type) { if (type == null) { - throw new GeneralException(PlayingErrorStatus.MISSING_MIDI_TYPE); + throw new GeneralException(MidiEventErrorStatus.INVALID_MIDI_TYPE); } } private static void validatePitch(Integer pitch) { if (pitch == null || pitch < 0 || pitch > 127) { - throw new GeneralException(PlayingErrorStatus.INVALID_PITCH_RANGE); + throw new GeneralException(MidiEventErrorStatus.INVALID_PITCH_RANGE); } } private static void validateVelocity(Integer velocity) { if (velocity == null || velocity < 0 || velocity > 127) { - throw new GeneralException(PlayingErrorStatus.INVALID_VELOCITY_RANGE); + throw new GeneralException(MidiEventErrorStatus.INVALID_VELOCITY_RANGE); } } private static void validateTimestampMs(Long timestampMs) { if (timestampMs == null || timestampMs < 0) { - throw new GeneralException(PlayingErrorStatus.INVALID_TIMESTAMP); + throw new GeneralException(MidiEventErrorStatus.INVALID_TIMESTAMP); } } } diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index 870c3edd..c92cf7e1 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -3,6 +3,7 @@ import com.mr.domain.backingTrack.entity.BackingTrack; 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.user.entity.User; import com.mr.global.apipayload.exception.GeneralException; @@ -201,7 +202,7 @@ public void completeWithMidiData( if (sortedMidiData.isEmpty()) { throw new GeneralException( - PlayingErrorStatus.EMPTY_MIDI_EVENTS + MidiEventErrorStatus.EMPTY_MIDI_EVENTS ); } @@ -213,9 +214,15 @@ public void completeWithMidiData( this.status = PlayingStatus.COMPLETED; } + public void validateOwner(Long userId) { + if (userId == null || this.user == null || !Objects.equals(this.user.getUserId(), userId)) { + throw new GeneralException(MidiEventErrorStatus.MIDI_EVENT_SAVE_FORBIDDEN); + } + } + private void validateCompletableStatus() { if (this.status != PlayingStatus.IN_PROGRESS) { - throw new GeneralException(PlayingErrorStatus.INVALID_PLAYING_STATUS); + throw new GeneralException(MidiEventErrorStatus.PLAYING_NOT_IN_PROGRESS); } if (startedAt == null) { @@ -225,15 +232,15 @@ private void validateCompletableStatus() { private static void validateMidiData(List midiData) { if (midiData == null || midiData.isEmpty()) { - throw new GeneralException(PlayingErrorStatus.EMPTY_MIDI_EVENTS); + throw new GeneralException(MidiEventErrorStatus.EMPTY_MIDI_EVENTS); } if (midiData.size() > MAX_MIDI_EVENT_COUNT) { - throw new GeneralException(PlayingErrorStatus.EXCEEDED_MIDI_EVENT_COUNT); + throw new GeneralException(MidiEventErrorStatus.EXCEEDED_MIDI_EVENT_COUNT); } if (midiData.stream().anyMatch(Objects::isNull)) { - throw new GeneralException(PlayingErrorStatus.INVALID_MIDI_EVENT); + throw new GeneralException(MidiEventErrorStatus.INVALID_MIDI_EVENT); } Set seenOrders = new HashSet<>(); @@ -246,7 +253,7 @@ private static void validateMidiData(List midiData) { if (!seenOrders.add(order)) { throw new GeneralException( - PlayingErrorStatus.DUPLICATE_MIDI_SEQUENCE + MidiEventErrorStatus.DUPLICATE_MIDI_SEQUENCE ); } } diff --git a/src/main/java/com/mr/domain/playing/exception/MidiEventErrorStatus.java b/src/main/java/com/mr/domain/playing/exception/MidiEventErrorStatus.java new file mode 100644 index 00000000..489b0b89 --- /dev/null +++ b/src/main/java/com/mr/domain/playing/exception/MidiEventErrorStatus.java @@ -0,0 +1,31 @@ +package com.mr.domain.playing.exception; + +import com.mr.global.apipayload.code.BaseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum MidiEventErrorStatus implements BaseCode { + + INVALID_PLAYING_ID(HttpStatus.BAD_REQUEST, "MIDI_400_01", "연주 ID가 올바르지 않습니다."), + EMPTY_MIDI_EVENTS(HttpStatus.BAD_REQUEST,"MIDI_400_02", "MIDI 이벤트 목록은 필수입니다."), + INVALID_MIDI_TYPE(HttpStatus.BAD_REQUEST, "MIDI_400_03", "MIDI 이벤트 타입이 올바르지 않습니다."), + INVALID_PITCH_RANGE(HttpStatus.BAD_REQUEST, "MIDI_400_04", "피치 값은 0~127 사이의 값이어야 합니다."), + INVALID_VELOCITY_RANGE(HttpStatus.BAD_REQUEST, "MIDI_400_05", "강도는 0~127 사이의 값이어야 합니다."), + INVALID_TIMESTAMP(HttpStatus.BAD_REQUEST, "MIDI_400_06", "MIDI 이벤트 타임스탬프는 0 이상이어야 합니다."), + INVALID_MIDI_EVENT(HttpStatus.BAD_REQUEST,"MIDI_400_07", "MIDI 이벤트 목록에 유효하지 않은 값이 포함되어 있습니다."), + INVALID_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "MIDI_400_08", "MIDI 이벤트 순서(sequence) 값이 유효하지 않습니다."), + EXCEEDED_MIDI_EVENT_COUNT(HttpStatus.BAD_REQUEST, "MIDI_400_09", "MIDI 이벤트 개수가 허용 범위를 초과했습니다."), + DUPLICATE_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "MIDI_400_10", "동일한 시간에 중복된 MIDI sequence 값이 존재합니다."), + + MIDI_EVENT_SAVE_FORBIDDEN(HttpStatus.FORBIDDEN, "MIDI_403_01", "MIDI 이벤트 저장 권한이 없습니다."), + PLAYING_NOT_IN_PROGRESS(HttpStatus.CONFLICT, "MIDI_409_01", "진행 중인 연주 세션에만 MIDI 이벤트를 저장할 수 있습니다."), + MIDI_EVENT_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "MIDI_500_01", "MIDI 이벤트 저장 중 서버 오류가 발생했습니다.") + ; + + private final HttpStatus status; + private final String code; + private final String message; +} 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 e445c7e8..ce289bcd 100644 --- a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java +++ b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java @@ -14,18 +14,10 @@ public enum PlayingErrorStatus implements BaseCode { MISSING_BACKING_TRACK_ID(HttpStatus.BAD_REQUEST, "PLAYING_400_03", "백킹트랙 연주 모드에서는 백킹트랙 ID가 필수입니다."), MISSING_PLAYING(HttpStatus.BAD_REQUEST, "PLAYING_400_04", "MIDI 이벤트를 기록할 연주 정보가 누락되었습니다."), MISSING_MIDI_TYPE(HttpStatus.BAD_REQUEST, "PLAYING_400_05", "MIDI Type은 필수 입력 값입니다."), - INVALID_PITCH_RANGE(HttpStatus.BAD_REQUEST, "PLAYING_400_06", "피치 값은 0~127 사이의 값이어야 합니다."), - INVALID_VELOCITY_RANGE(HttpStatus.BAD_REQUEST, "PLAYING_400_07", "강도는 0~127 사이의 값이어야 합니다."), - INVALID_TIMESTAMP(HttpStatus.BAD_REQUEST, "PLAYING_400_08", "MIDI 이벤트 타임스탬프는 0 이상이어야 합니다."), - EMPTY_MIDI_EVENTS(HttpStatus.BAD_REQUEST, "PLAYING_400_09", "저장할 MIDI 이벤트가 없습니다."), - INVALID_MIDI_EVENT(HttpStatus.BAD_REQUEST,"PLAYING_400_10", "MIDI 이벤트 목록에 유효하지 않은 값이 포함되어 있습니다."), - MISSING_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_11", "연주 모드는 필수 입력값입니다."), - MISSING_PLAYING_STATUS(HttpStatus.BAD_REQUEST, "PLAYING_400_12", "연주 상태는 필수 입력값입니다."), - UNSUPPORTED_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_13", "현재 지원하지 않는 연주 모드입니다."), - EXCEEDED_MIDI_EVENT_COUNT(HttpStatus.BAD_REQUEST, "PLAYING_400_14", "MIDI 이벤트 개수가 허용 범위를 초과했습니다."), - INVALID_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "PLAYING_400_15", "MIDI 이벤트 순서(sequence) 값이 유효하지 않습니다."), - DUPLICATE_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "PLAYING_400_16", "중복된 MIDI sequence 값이 존재합니다."), - + MISSING_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_06", "연주 모드는 필수 입력값입니다."), + MISSING_PLAYING_STATUS(HttpStatus.BAD_REQUEST, "PLAYING_400_07", "연주 상태는 필수 입력값입니다."), + UNSUPPORTED_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_08", "현재 지원하지 않는 연주 모드입니다."), + PLAYING_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAYING_404_01", "연주 세션을 찾을 수 없습니다."), 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", "연주 종료 시간이 시작 시간보다 이전일 수 없습니다."), From 366e478b4bd3dd8d10bcd235180cdcad4789fea4 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Wed, 29 Jul 2026 21:43:47 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20MIDI=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=80=EC=9E=A5=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playing/dto/req/MidiEventSaveRequest.java | 41 +++++++++++++ .../dto/res/MidiEventSaveResponse.java | 11 ++++ .../playing/service/PlayingService.java | 58 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 src/main/java/com/mr/domain/playing/dto/req/MidiEventSaveRequest.java create mode 100644 src/main/java/com/mr/domain/playing/dto/res/MidiEventSaveResponse.java create mode 100644 src/main/java/com/mr/domain/playing/service/PlayingService.java 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 new file mode 100644 index 00000000..8186205e --- /dev/null +++ b/src/main/java/com/mr/domain/playing/dto/req/MidiEventSaveRequest.java @@ -0,0 +1,41 @@ +package com.mr.domain.playing.dto.req; + +import com.mr.domain.playing.entity.enums.MidiType; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record MidiEventSaveRequest ( + @NotEmpty(message = "MIDI 이벤트 목록은 필수입니다") + List<@Valid MidiEventRequest> events +) { + + public record MidiEventRequest ( + + @NotNull(message = "MIDI 이벤트 순서 값은 필수입니다.") + @Min(value = 0, message = "MIDI 이벤트 순서 값은 0 이상이어야 합니다.") + Integer sequence, + + @NotNull(message = "MIDI 이벤트 타입은 필수입니다.") + MidiType type, + + @NotNull(message = "MIDI 피치 값은 필수입니다.") + @Min(value = 0, message = "MIDI 피치 값은 0 이상이어야 합니다.") + @Max(value = 127, message = "MIDI 피치 값은 127 이하여야 합니다.") + Integer pitch, + + @NotNull(message = "MIDI 입력 강도 값은 필수입니다.") + @Min(value = 0, message = "MIDI 입력 강도 값은 0 이상이어야 합니다.") + @Max(value = 127, message = "MIDI 입력 강도 값은 127 이하여야 합니다.") + Integer velocity, + + @NotNull(message = "MIDI 이벤트 발생 시간은 필수입니다.") + @Min(value = 0, message = "MIDI 이벤트 발생 시간은 0 이상이어야 합니다.") + Long timestampMs + ) { + } +} diff --git a/src/main/java/com/mr/domain/playing/dto/res/MidiEventSaveResponse.java b/src/main/java/com/mr/domain/playing/dto/res/MidiEventSaveResponse.java new file mode 100644 index 00000000..57dd6bf8 --- /dev/null +++ b/src/main/java/com/mr/domain/playing/dto/res/MidiEventSaveResponse.java @@ -0,0 +1,11 @@ +package com.mr.domain.playing.dto.res; + +public record MidiEventSaveResponse( + Long playingId, + int savedCount +) { + + public static MidiEventSaveResponse of(Long playingId, int savedCount) { + return new MidiEventSaveResponse(playingId, savedCount); + } +} diff --git a/src/main/java/com/mr/domain/playing/service/PlayingService.java b/src/main/java/com/mr/domain/playing/service/PlayingService.java new file mode 100644 index 00000000..6bc0a29b --- /dev/null +++ b/src/main/java/com/mr/domain/playing/service/PlayingService.java @@ -0,0 +1,58 @@ +package com.mr.domain.playing.service; + +import com.mr.domain.playing.dto.req.MidiEventSaveRequest; +import com.mr.domain.playing.dto.res.MidiEventSaveResponse; +import com.mr.domain.playing.entity.MidiEventData; +import com.mr.domain.playing.entity.Playing; +import com.mr.domain.playing.exception.MidiEventErrorStatus; +import com.mr.domain.playing.exception.PlayingErrorStatus; +import com.mr.domain.playing.repository.PlayingRepository; +import com.mr.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PlayingService { + + private final PlayingRepository playingRepository; + + @Transactional + public MidiEventSaveResponse saveMidiEvents( + Long userId, Long playingId, MidiEventSaveRequest request + ) { + validatePlayingId(playingId); + + Playing playing = playingRepository.findById(playingId) + .orElseThrow(() -> new GeneralException(PlayingErrorStatus.PLAYING_NOT_FOUND)); + + playing.validateOwner(userId); + + List midiEvents = request.events() + .stream() + .map(event -> MidiEventData.of( + event.sequence(), + event.type(), + event.pitch(), + event.velocity(), + event.timestampMs() + )).toList(); + + playing.completeWithMidiData(midiEvents); + + return MidiEventSaveResponse.of( + playing.getId(), + playing.getMidiData().size() + ); + } + + private void validatePlayingId(Long playingId) { + if (playingId == null || playingId < 1 ) { + throw new GeneralException(MidiEventErrorStatus.INVALID_PLAYING_ID); + } + } +} From 63bb0b9f10b4cb8eb10467c4d92b24846d6a19ce Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Wed, 29 Jul 2026 21:44:16 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20MIDI=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=80=EC=9E=A5=20API=20=EC=B6=94=EA=B0=80=20(#2?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playing/controller/PlayingController.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/main/java/com/mr/domain/playing/controller/PlayingController.java diff --git a/src/main/java/com/mr/domain/playing/controller/PlayingController.java b/src/main/java/com/mr/domain/playing/controller/PlayingController.java new file mode 100644 index 00000000..d30f4c13 --- /dev/null +++ b/src/main/java/com/mr/domain/playing/controller/PlayingController.java @@ -0,0 +1,40 @@ +package com.mr.domain.playing.controller; + +import com.mr.domain.playing.dto.req.MidiEventSaveRequest; +import com.mr.domain.playing.dto.res.MidiEventSaveResponse; +import com.mr.domain.playing.service.PlayingService; +import com.mr.global.apipayload.ApiResponse; +import com.mr.global.security.principal.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/playings") +public class PlayingController { + + private final PlayingService playingService; + + @PostMapping("/{playingId}/midi-events") + public ApiResponse saveMidiEvents( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long playingId, + @Valid @RequestBody MidiEventSaveRequest request + ){ + Long userId = userDetails.getUserId(); + + MidiEventSaveResponse response = playingService.saveMidiEvents( + userId, + playingId, + request + ); + + return ApiResponse.onSuccess(response); + } +} From 94216349eb91977b2fb0027cf3e8955b60852143 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Wed, 29 Jul 2026 21:46:00 +0900 Subject: [PATCH 04/14] =?UTF-8?q?test:=20MIDI=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=80=EC=9E=A5=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=C2=B7=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PlayingControllerTest.java | 156 ++++++++++ .../playing/service/PlayingServiceTest.java | 281 ++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java create mode 100644 src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java diff --git a/src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java b/src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java new file mode 100644 index 00000000..bd0351c0 --- /dev/null +++ b/src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java @@ -0,0 +1,156 @@ +package com.mr.domain.playing.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mr.domain.playing.dto.req.MidiEventSaveRequest; +import com.mr.domain.playing.dto.res.MidiEventSaveResponse; +import com.mr.domain.playing.entity.enums.MidiType; +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.security.principal.CustomUserDetails; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class PlayingControllerTest { + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @Mock + private PlayingService playingService; + + @BeforeEach + void setUp() { + objectMapper = Jackson2ObjectMapperBuilder.json() + .findModulesViaServiceLoader(true) + .build(); + + mockMvc = MockMvcBuilders + .standaloneSetup(new PlayingController(playingService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .setMessageConverters( + new MappingJackson2HttpMessageConverter(objectMapper) + ) + .setCustomArgumentResolvers( + new AuthenticationPrincipalArgumentResolver() + ) + .build(); + + CustomUserDetails userDetails = + new CustomUserDetails( + 1L, + UserRole.ROLE_STUDENT + ); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userDetails, + "", + userDetails.getAuthorities() + ) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("POST /api/playings/{playingId}/midi-events - MIDI 이벤트 저장 성공") + void saveMidiEvents_success() throws Exception { + // given + Long userId = 1L; + Long playingId = 10L; + + MidiEventSaveRequest request = createRequest(); + + MidiEventSaveResponse response = + new MidiEventSaveResponse( + playingId, + 2 + ); + + given( + playingService.saveMidiEvents( + anyLong(), + anyLong(), + any(MidiEventSaveRequest.class) + ) + ).willReturn(response); + + // when & then + mockMvc.perform( + post( + "/api/playings/{playingId}/midi-events", + playingId + ) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString(request) + ) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect( + jsonPath("$.data.playingId") + .value(playingId) + ) + .andExpect( + jsonPath("$.data.savedCount") + .value(2) + ); + + then(playingService) + .should() + .saveMidiEvents( + userId, + playingId, + request + ); + } + + private MidiEventSaveRequest createRequest() { + return new MidiEventSaveRequest( + List.of( + new MidiEventSaveRequest.MidiEventRequest( + 0, + MidiType.NOTE_ON, + 60, + 100, + 0L + ), + new MidiEventSaveRequest.MidiEventRequest( + 1, + MidiType.NOTE_OFF, + 60, + 0, + 500L + ) + ) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java new file mode 100644 index 00000000..4813cd90 --- /dev/null +++ b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java @@ -0,0 +1,281 @@ +package com.mr.domain.playing.service; + +import com.mr.domain.playing.dto.req.MidiEventSaveRequest; +import com.mr.domain.playing.dto.res.MidiEventSaveResponse; +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.exception.MidiEventErrorStatus; +import com.mr.domain.playing.exception.PlayingErrorStatus; +import com.mr.domain.playing.repository.PlayingRepository; +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.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.anyList; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PlayingServiceTest { + + @Mock + private PlayingRepository playingRepository; + + @Mock + private Playing playing; + + @InjectMocks + private PlayingService playingService; + + private Long userId; + private Long playingId; + + @BeforeEach + void setUp() { + userId = 1L; + playingId = 10L; + } + + @Nested + @DisplayName("MIDI 이벤트 저장") + class SaveMidiEvents { + + @Test + @DisplayName("진행 중인 본인의 연주에 MIDI 이벤트를 저장한다") + void saveMidiEvents_success() { + // given + MidiEventSaveRequest request = createRequest(); + + when(playingRepository.findById(playingId)) + .thenReturn(Optional.of(playing)); + + when(playing.getId()) + .thenReturn(playingId); + + /* + * Playing을 Mock으로 사용하면 completeWithMidiData()가 실제로 + * midiData를 저장하지 않으므로, 전달받은 값을 getMidiData()가 + * 반환하도록 구성합니다. + */ + doAnswer(invocation -> { + List midiEvents = + invocation.getArgument(0); + + when(playing.getMidiData()) + .thenReturn(midiEvents); + + return null; + }).when(playing).completeWithMidiData(anyList()); + + // when + MidiEventSaveResponse response = + playingService.saveMidiEvents( + userId, + playingId, + request + ); + + // then + assertThat(response.playingId()) + .isEqualTo(playingId); + + assertThat(response.savedCount()) + .isEqualTo(2); + + verify(playingRepository) + .findById(playingId); + + verify(playing) + .validateOwner(userId); + + verify(playing) + .completeWithMidiData(anyList()); + } + + @Test + @DisplayName("요청 DTO의 MIDI 이벤트를 엔티티 값 객체로 변환한다") + void saveMidiEvents_convertsRequestToEntity() { + // given + MidiEventSaveRequest request = createRequest(); + + when(playingRepository.findById(playingId)) + .thenReturn(Optional.of(playing)); + + when(playing.getId()) + .thenReturn(playingId); + + doAnswer(invocation -> { + List midiEvents = + invocation.getArgument(0); + + assertThat(midiEvents) + .hasSize(2); + + MidiEventData firstEvent = midiEvents.get(0); + + assertThat(firstEvent.getSequence()) + .isZero(); + + assertThat(firstEvent.getType()) + .isEqualTo(MidiType.NOTE_ON); + + assertThat(firstEvent.getPitch()) + .isEqualTo(60); + + assertThat(firstEvent.getVelocity()) + .isEqualTo(100); + + assertThat(firstEvent.getTimestampMs()) + .isEqualTo(0L); + + when(playing.getMidiData()) + .thenReturn(midiEvents); + + return null; + }).when(playing).completeWithMidiData(anyList()); + + // when + playingService.saveMidiEvents( + userId, + playingId, + request + ); + + // then + verify(playing) + .completeWithMidiData(anyList()); + } + + @Test + @DisplayName("playingId가 null이면 예외가 발생한다") + void saveMidiEvents_nullPlayingId() { + // given + MidiEventSaveRequest request = createRequest(); + + // when & then + assertThatThrownBy(() -> + playingService.saveMidiEvents( + userId, + null, + request + ) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + MidiEventErrorStatus + .INVALID_PLAYING_ID + ); + }); + + verify(playingRepository, never()) + .findById(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("playingId가 0 이하이면 예외가 발생한다") + void saveMidiEvents_invalidPlayingId() { + // given + MidiEventSaveRequest request = createRequest(); + + // when & then + assertThatThrownBy(() -> + playingService.saveMidiEvents( + userId, + 0L, + request + ) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + MidiEventErrorStatus + .INVALID_PLAYING_ID + ); + }); + + verify(playingRepository, never()) + .findById(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("연주가 존재하지 않으면 예외가 발생한다") + void saveMidiEvents_playingNotFound() { + // given + MidiEventSaveRequest request = createRequest(); + + when(playingRepository.findById(playingId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> + playingService.saveMidiEvents( + userId, + playingId, + request + ) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + PlayingErrorStatus + .PLAYING_NOT_FOUND + ); + }); + + verify(playing, never()) + .validateOwner(userId); + + verify(playing, never()) + .completeWithMidiData(anyList()); + } + } + + private MidiEventSaveRequest createRequest() { + List events = + List.of( + new MidiEventSaveRequest.MidiEventRequest( + 0, + MidiType.NOTE_ON, + 60, + 100, + 0L + ), + new MidiEventSaveRequest.MidiEventRequest( + 1, + MidiType.NOTE_OFF, + 60, + 0, + 500L + ) + ); + + return new MidiEventSaveRequest(events); + } +} \ No newline at end of file From dbd87087790ad23f40847169b2e7f28563de2505 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Wed, 29 Jul 2026 21:48:14 +0900 Subject: [PATCH 05/14] =?UTF-8?q?docs:=20MIDI=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=80=EC=9E=A5=20API=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/playing/controller/PlayingController.java | 5 +++++ 1 file changed, 5 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 d30f4c13..80169b39 100644 --- a/src/main/java/com/mr/domain/playing/controller/PlayingController.java +++ b/src/main/java/com/mr/domain/playing/controller/PlayingController.java @@ -5,6 +5,7 @@ import com.mr.domain.playing.service.PlayingService; import com.mr.global.apipayload.ApiResponse; import com.mr.global.security.principal.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -21,6 +22,10 @@ public class PlayingController { private final PlayingService playingService; + @Operation( + summary = "MIDI 이벤트 저장", + description = "연주 세션에 대한 MIDI 이벤트를 저장하고 연주를 완료 상태로 변경합니다." + ) @PostMapping("/{playingId}/midi-events") public ApiResponse saveMidiEvents( @AuthenticationPrincipal CustomUserDetails userDetails, From 4b7b29dbfe742482ce71d354b73231b65809b627 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Wed, 29 Jul 2026 22:03:53 +0900 Subject: [PATCH 06/14] =?UTF-8?q?refactor:=20MIDI=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9A=94=EC=B2=AD=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/playing/dto/req/MidiEventSaveRequest.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 8186205e..7d2c9629 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 @@ -2,15 +2,13 @@ import com.mr.domain.playing.entity.enums.MidiType; import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.*; import java.util.List; public record MidiEventSaveRequest ( @NotEmpty(message = "MIDI 이벤트 목록은 필수입니다") + @Size(max = 100_000, message = "MIDI 이벤트 개수가 허용 범위를 초과했습니다.") List<@Valid MidiEventRequest> events ) { From b37a6c378aadec448b4cae0a388b939c584cc62c Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Wed, 29 Jul 2026 22:07:01 +0900 Subject: [PATCH 07/14] =?UTF-8?q?refactor:=20Playing=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EB=B2=88=ED=98=B8=20=EB=B3=B5=EC=9B=90?= =?UTF-8?q?=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/playing/exception/PlayingErrorStatus.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 ce289bcd..59a7d3bf 100644 --- a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java +++ b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java @@ -14,9 +14,10 @@ public enum PlayingErrorStatus implements BaseCode { MISSING_BACKING_TRACK_ID(HttpStatus.BAD_REQUEST, "PLAYING_400_03", "백킹트랙 연주 모드에서는 백킹트랙 ID가 필수입니다."), MISSING_PLAYING(HttpStatus.BAD_REQUEST, "PLAYING_400_04", "MIDI 이벤트를 기록할 연주 정보가 누락되었습니다."), MISSING_MIDI_TYPE(HttpStatus.BAD_REQUEST, "PLAYING_400_05", "MIDI Type은 필수 입력 값입니다."), - MISSING_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_06", "연주 모드는 필수 입력값입니다."), - MISSING_PLAYING_STATUS(HttpStatus.BAD_REQUEST, "PLAYING_400_07", "연주 상태는 필수 입력값입니다."), - UNSUPPORTED_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_08", "현재 지원하지 않는 연주 모드입니다."), + // 기존 PLAYING_400_06 ~ PLAYING_400_10은 MidiEventErrorStatus로 분리 + MISSING_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_11", "연주 모드는 필수 입력값입니다."), + MISSING_PLAYING_STATUS(HttpStatus.BAD_REQUEST, "PLAYING_400_12", "연주 상태는 필수 입력값입니다."), + UNSUPPORTED_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_13", "현재 지원하지 않는 연주 모드입니다."), PLAYING_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAYING_404_01", "연주 세션을 찾을 수 없습니다."), INVALID_PLAYING_STATUS(HttpStatus.CONFLICT, "PLAYING_409_01", "현재 연주 상태에서는 요청한 작업을 수행할 수 없습니다."), MISSING_PLAYING_START_TIME(HttpStatus.CONFLICT, "PLAYING_409_02", "연주 시작 시간이 기록되지 않았습니다."), From 65fac6773dd934343a37c354bbfd5a954ad07e61 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Thu, 30 Jul 2026 18:19:34 +0900 Subject: [PATCH 08/14] =?UTF-8?q?refactor:=20MAX=5FMIDI=5FEVENT=5FCOUNT=20?= =?UTF-8?q?=EC=83=81=EC=88=98=20=EA=B3=B5=ED=86=B5=ED=99=94=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/playing/constant/MidiEventConstants.java | 10 ++++++++++ .../domain/playing/dto/req/MidiEventSaveRequest.java | 10 ++++++++-- .../java/com/mr/domain/playing/entity/Playing.java | 3 ++- 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/mr/domain/playing/constant/MidiEventConstants.java diff --git a/src/main/java/com/mr/domain/playing/constant/MidiEventConstants.java b/src/main/java/com/mr/domain/playing/constant/MidiEventConstants.java new file mode 100644 index 00000000..faf4290e --- /dev/null +++ b/src/main/java/com/mr/domain/playing/constant/MidiEventConstants.java @@ -0,0 +1,10 @@ +package com.mr.domain.playing.constant; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class MidiEventConstants { + + public static final int MAX_MIDI_EVENT_COUNT = 100_000; +} 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 7d2c9629..bfde1802 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 @@ -2,13 +2,19 @@ import com.mr.domain.playing.entity.enums.MidiType; import jakarta.validation.Valid; -import jakarta.validation.constraints.*; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; import java.util.List; +import static com.mr.domain.playing.constant.MidiEventConstants.MAX_MIDI_EVENT_COUNT; + public record MidiEventSaveRequest ( @NotEmpty(message = "MIDI 이벤트 목록은 필수입니다") - @Size(max = 100_000, message = "MIDI 이벤트 개수가 허용 범위를 초과했습니다.") + @Size(max = MAX_MIDI_EVENT_COUNT, message = "MIDI 이벤트는 최대 100,000개까지 저장할 수 있습니다.") List<@Valid MidiEventRequest> events ) { diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index d350f56c..91aacee2 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -37,6 +37,8 @@ import java.util.Objects; import java.util.Set; +import static com.mr.domain.playing.constant.MidiEventConstants.MAX_MIDI_EVENT_COUNT; + @Entity @Table( name = "playing", @@ -56,7 +58,6 @@ public class Playing extends BaseCreatedDeletedEntity { private static final long MAX_DURATION_MS = MAX_DURATION_SEC * 1000L; private static final long MIDI_TIMESTAMP_TOLERANCE_MS = 500L; // 실제 연주 시간과 MIDI 이벤트 수집 종료 시점 간 허용 오차 - private static final int MAX_MIDI_EVENT_COUNT = 100_000; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) From 93e7275b524bbf1008d84f6f1aeac67d8c7f0f83 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Thu, 30 Jul 2026 18:22:50 +0900 Subject: [PATCH 09/14] =?UTF-8?q?refactor:=20=EC=86=8C=ED=94=84=ED=8A=B8?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=EB=90=9C=20=EC=97=B0=EC=A3=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=A0=9C=EC=99=B8=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/playing/repository/PlayingRepository.java | 2 ++ src/main/java/com/mr/domain/playing/service/PlayingService.java | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) 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 655a40b5..bdd24e6b 100644 --- a/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java +++ b/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java @@ -64,4 +64,6 @@ List findDistinctEndedDatesByUserAndStatus( @Param("userId") Long userId, @Param("status") PlayingStatus status ); + + Optional findByIdAndDeletedAtIsNull(Long playingId); } 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 6bc0a29b..653b727d 100644 --- a/src/main/java/com/mr/domain/playing/service/PlayingService.java +++ b/src/main/java/com/mr/domain/playing/service/PlayingService.java @@ -27,7 +27,7 @@ public MidiEventSaveResponse saveMidiEvents( ) { validatePlayingId(playingId); - Playing playing = playingRepository.findById(playingId) + Playing playing = playingRepository.findByIdAndDeletedAtIsNull(playingId) .orElseThrow(() -> new GeneralException(PlayingErrorStatus.PLAYING_NOT_FOUND)); playing.validateOwner(userId); From 814256158f8ad8ab8bed05908a291c8581e70fcd Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Thu, 30 Jul 2026 18:24:50 +0900 Subject: [PATCH 10/14] =?UTF-8?q?refactor:=20=EC=86=8C=EC=9C=A0=EA=B6=8C?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EB=B2=94=EC=9A=A9?= =?UTF-8?q?=ED=99=94=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/playing/entity/Playing.java | 4 ++-- .../com/mr/domain/playing/exception/PlayingErrorStatus.java | 1 + .../java/com/mr/domain/playing/service/PlayingService.java | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index 91aacee2..f3198b62 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -222,9 +222,9 @@ public void completeWithMidiData( this.status = PlayingStatus.COMPLETED; } - public void validateOwner(Long userId) { + public void validatePlayingOwner(Long userId) { if (userId == null || this.user == null || !Objects.equals(this.user.getUserId(), userId)) { - throw new GeneralException(MidiEventErrorStatus.MIDI_EVENT_SAVE_FORBIDDEN); + throw new GeneralException(PlayingErrorStatus.PLAYING_ACCESS_DENIED); } } 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 59a7d3bf..473e3ef4 100644 --- a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java +++ b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java @@ -22,6 +22,7 @@ public enum PlayingErrorStatus implements BaseCode { 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", "연주 종료 시간이 시작 시간보다 이전일 수 없습니다."), + PLAYING_ACCESS_DENIED(HttpStatus.FORBIDDEN, "PLAYING_409_04", "해당 연주에 대한 접근 권한이 없습니다."), ; 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 653b727d..78fd4a0b 100644 --- a/src/main/java/com/mr/domain/playing/service/PlayingService.java +++ b/src/main/java/com/mr/domain/playing/service/PlayingService.java @@ -30,7 +30,7 @@ public MidiEventSaveResponse saveMidiEvents( Playing playing = playingRepository.findByIdAndDeletedAtIsNull(playingId) .orElseThrow(() -> new GeneralException(PlayingErrorStatus.PLAYING_NOT_FOUND)); - playing.validateOwner(userId); + playing.validatePlayingOwner(userId); List midiEvents = request.events() .stream() From 9cac136a0489ecdd18f78d7f4a74464f56c81345 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Thu, 30 Jul 2026 21:53:49 +0900 Subject: [PATCH 11/14] =?UTF-8?q?refactor:=20Playing=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20MIDI=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/playing/entity/Playing.java | 84 ++++++++++++++----- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index f3198b62..82332ddd 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -189,36 +189,20 @@ public static Playing createBackingTrack( // 연주 완료 시 전체 MIDI 데이터를 저장하고 완료 상태로 전환 public void completeWithMidiData( - List midiData + List requestedMidiData ) { validateCompletableStatus(); - validateMidiData(midiData); + validateMidiData(requestedMidiData); LocalDateTime completedAt = LocalDateTime.now(); long savedDurationMs = calculateDurationMs(this.startedAt, completedAt); - // 실제 연주 시간에 500ms의 오차를 허용하되, 최대 10분까지만 저장 - long allowedTimestampMs = Math.min(savedDurationMs + MIDI_TIMESTAMP_TOLERANCE_MS, - MAX_DURATION_MS); + List normalizedMidiData = normalizeMidiData(requestedMidiData, savedDurationMs); + validateNormalizedMidiData(normalizedMidiData); - List sortedMidiData = midiData.stream() - .filter(event -> event.getTimestampMs() <= allowedTimestampMs) - .sorted( - Comparator.comparingLong(MidiEventData::getTimestampMs) - .thenComparingInt(MidiEventData::getSequence)) - .toList(); - - if (sortedMidiData.isEmpty()) { - throw new GeneralException( - MidiEventErrorStatus.EMPTY_MIDI_EVENTS - ); - } - - LocalDateTime maxEndedAt = this.startedAt.plusSeconds(MAX_DURATION_SEC); - - this.midiData = new ArrayList<>(sortedMidiData); - this.endedAt = completedAt.isAfter(maxEndedAt) ? maxEndedAt : completedAt; - this.durationSec = Math.toIntExact(Duration.ofMillis(savedDurationMs).toSeconds()); + this.midiData = new ArrayList<>(normalizedMidiData); + this.endedAt = calculateEndedAt(completedAt); + this.durationSec = convertToDurationSec(savedDurationMs); this.status = PlayingStatus.COMPLETED; } @@ -251,6 +235,10 @@ private static void validateMidiData(List midiData) { throw new GeneralException(MidiEventErrorStatus.INVALID_MIDI_EVENT); } + if (midiData.stream().anyMatch(Playing::hasInvalidEventOrder)) { + throw new GeneralException(MidiEventErrorStatus.INVALID_MIDI_EVENT); + } + Set seenOrders = new HashSet<>(); for (MidiEventData event : midiData) { @@ -267,6 +255,16 @@ private static void validateMidiData(List midiData) { } } + private static void validateNormalizedMidiData( + List normalizedMidiData + ) { + if (normalizedMidiData.isEmpty()) { + throw new GeneralException( + MidiEventErrorStatus.EMPTY_MIDI_EVENTS + ); + } + } + private record MidiEventOrder( Long timestampMs, Integer sequence @@ -285,6 +283,25 @@ private static long calculateDurationMs( return Math.min(durationMs, MAX_DURATION_MS); } + private LocalDateTime calculateEndedAt( + LocalDateTime completedAt + ) { + LocalDateTime maxEndedAt = + this.startedAt.plusSeconds(MAX_DURATION_SEC); + + return completedAt.isAfter(maxEndedAt) + ? maxEndedAt + : completedAt; + } + + private static int convertToDurationSec( + long durationMs + ) { + return Math.toIntExact( + Duration.ofMillis(durationMs).toSeconds() + ); + } + public List getMidiData() { if (this.midiData == null) { @@ -305,4 +322,25 @@ private void validateStartableStatus() { throw new GeneralException(PlayingErrorStatus.INVALID_PLAYING_STATUS); } } + + private static List normalizeMidiData( + List midiData, long savedDurationMs) { + long allowedTimestampMs = + Math.min(savedDurationMs + MIDI_TIMESTAMP_TOLERANCE_MS, MAX_DURATION_MS); + + return midiData.stream() + .filter(event -> event.getTimestampMs() <= allowedTimestampMs) + .sorted(Comparator.comparingLong(MidiEventData::getTimestampMs) + .thenComparingInt(MidiEventData::getSequence) + ).toList(); + } + + private static boolean hasInvalidEventOrder( + MidiEventData event + ) { + return event.getTimestampMs() == null + || event.getTimestampMs() < 0 + || event.getSequence() == null + || event.getSequence() < 0; + } } From c98d90c886ddc7d77c72a599b86966969c48f77f Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Thu, 30 Jul 2026 21:54:10 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=EC=97=B0=EC=A3=BC=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=9A=94=EC=B2=AD=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=EC=B6=94=EA=B0=80=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/MidiEventErrorStatus.java | 3 +-- .../playing/repository/PlayingRepository.java | 6 +++++ .../playing/service/PlayingService.java | 27 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 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 489b0b89..4a3a0b30 100644 --- a/src/main/java/com/mr/domain/playing/exception/MidiEventErrorStatus.java +++ b/src/main/java/com/mr/domain/playing/exception/MidiEventErrorStatus.java @@ -20,9 +20,8 @@ public enum MidiEventErrorStatus implements BaseCode { EXCEEDED_MIDI_EVENT_COUNT(HttpStatus.BAD_REQUEST, "MIDI_400_09", "MIDI 이벤트 개수가 허용 범위를 초과했습니다."), DUPLICATE_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "MIDI_400_10", "동일한 시간에 중복된 MIDI sequence 값이 존재합니다."), - MIDI_EVENT_SAVE_FORBIDDEN(HttpStatus.FORBIDDEN, "MIDI_403_01", "MIDI 이벤트 저장 권한이 없습니다."), PLAYING_NOT_IN_PROGRESS(HttpStatus.CONFLICT, "MIDI_409_01", "진행 중인 연주 세션에만 MIDI 이벤트를 저장할 수 있습니다."), - MIDI_EVENT_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "MIDI_500_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 bdd24e6b..cdcca246 100644 --- a/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java +++ b/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java @@ -66,4 +66,10 @@ List findDistinctEndedDatesByUserAndStatus( ); 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 78fd4a0b..cf0afb8e 100644 --- a/src/main/java/com/mr/domain/playing/service/PlayingService.java +++ b/src/main/java/com/mr/domain/playing/service/PlayingService.java @@ -4,6 +4,7 @@ import com.mr.domain.playing.dto.res.MidiEventSaveResponse; 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; @@ -12,6 +13,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; @Service @@ -19,6 +21,7 @@ @Transactional(readOnly = true) public class PlayingService { + private static final long MIDI_SAVE_REQUEST_INTERVAL_MINUTES = 1L; private final PlayingRepository playingRepository; @Transactional @@ -31,6 +34,7 @@ public MidiEventSaveResponse saveMidiEvents( .orElseThrow(() -> new GeneralException(PlayingErrorStatus.PLAYING_NOT_FOUND)); playing.validatePlayingOwner(userId); + validateMidiSaveRequestInterval(userId); List midiEvents = request.events() .stream() @@ -55,4 +59,27 @@ private void validatePlayingId(Long playingId) { throw new GeneralException(MidiEventErrorStatus.INVALID_PLAYING_ID); } } + + 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 + ); + } + } } From 7aac8a82c4e3d26faf6fff1a537a38ab95853e93 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Thu, 30 Jul 2026 21:55:10 +0900 Subject: [PATCH 13/14] =?UTF-8?q?test:=20MIDI=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=80=EC=9E=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PlayingControllerTest.java | 278 ++++++-- .../dto/req/MidiEventSaveRequestTest.java | 369 +++++++++++ .../mr/domain/playing/entity/PlayingTest.java | 596 ++++++++++++++++++ .../playing/service/PlayingServiceTest.java | 152 ++++- 4 files changed, 1342 insertions(+), 53 deletions(-) create mode 100644 src/test/java/com/mr/domain/playing/dto/req/MidiEventSaveRequestTest.java create mode 100644 src/test/java/com/mr/domain/playing/entity/PlayingTest.java 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 bd0351c0..02b32467 100644 --- a/src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java +++ b/src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java @@ -8,7 +8,11 @@ import com.mr.domain.user.entity.enums.UserRole; import com.mr.global.apipayload.handler.GlobalExceptionHandler; import com.mr.global.security.principal.CustomUserDetails; + +import java.util.ArrayList; import java.util.List; + +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -25,10 +29,12 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +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.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -36,6 +42,10 @@ @ExtendWith(MockitoExtension.class) class PlayingControllerTest { + private static final Long USER_ID = 1L; + private static final Long PLAYING_ID = 10L; + + private MockMvc mockMvc; private ObjectMapper objectMapper; @@ -79,60 +89,238 @@ void tearDown() { SecurityContextHolder.clearContext(); } - @Test - @DisplayName("POST /api/playings/{playingId}/midi-events - MIDI 이벤트 저장 성공") - void saveMidiEvents_success() throws Exception { - // given - Long userId = 1L; - Long playingId = 10L; + @Nested + @DisplayName("MIDI 이벤트 저장 성공") + class SaveMidiEventsSuccess { + @Test + @DisplayName("POST /api/playings/{playingId}/midi-events - MIDI 이벤트 저장 성공") + void saveMidiEvents_success() throws Exception { + // given - MidiEventSaveRequest request = createRequest(); + MidiEventSaveRequest request = createRequest(); - MidiEventSaveResponse response = - new MidiEventSaveResponse( - playingId, - 2 - ); + MidiEventSaveResponse response = + new MidiEventSaveResponse( + PLAYING_ID, + 2 + ); - given( - playingService.saveMidiEvents( - anyLong(), - anyLong(), - any(MidiEventSaveRequest.class) - ) - ).willReturn(response); + given( + playingService.saveMidiEvents( + anyLong(), + anyLong(), + any(MidiEventSaveRequest.class) + ) + ).willReturn(response); + + // when & then + mockMvc.perform( + post( + "/api/playings/{playingId}/midi-events", + PLAYING_ID + ) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString(request) + ) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect( + jsonPath("$.data.playingId") + .value(PLAYING_ID) + ) + .andExpect( + jsonPath("$.data.savedCount") + .value(2) + ); + + then(playingService) + .should() + .saveMidiEvents( + USER_ID, + PLAYING_ID, + request + ); + } + } + + @Nested + @DisplayName("MIDI 이벤트 저장 요청 검증 실패") + class SaveMidiEventsValidationFailure { + + @Test + @DisplayName("events 필드가 누락되면 요청에 실패한다") + void saveMidiEvents_eventsMissing() throws Exception { + // given + String requestBody = """ + { + } + """; + + // when & then + mockMvc.perform( + post( + "/api/playings/{playingId}/midi-events", + PLAYING_ID + ) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)); + + verifyServiceNotCalled(); + } + + @Test + @DisplayName("events가 빈 배열이면 요청에 실패한다") + void saveMidiEvents_eventsEmpty() throws Exception { + // given + String requestBody = """ + { + "events": [] + } + """; + + // when & then + mockMvc.perform( + post( + "/api/playings/{playingId}/midi-events", + PLAYING_ID + ) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)); + + verifyServiceNotCalled(); + } + + @Test + @DisplayName("MIDI 이벤트 sequence가 음수이면 요청에 실패한다") + void saveMidiEvents_negativeSequence() throws Exception { + String requestBody = """ + { + "events": [ + { + "sequence": -1, + "type": "NOTE_ON", + "pitch": 60, + "velocity": 100, + "timestampMs": 0 + } + ] + } + """; + + mockMvc.perform( + post( + "/api/playings/{playingId}/midi-events", + PLAYING_ID + ) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + ) + .andExpect(status().isBadRequest()) + .andExpect( + jsonPath("$.isSuccess") + .value(false) + ); - // when & then - mockMvc.perform( - post( - "/api/playings/{playingId}/midi-events", - playingId + verifyServiceNotCalled(); + } + + @Test + @DisplayName("정의되지 않은 MidiType이면 공통 예외 응답을 반환한다") + void saveMidiEvents_invalidMidiType() throws Exception { + // given + String requestBody = """ + { + "events": [ + { + "sequence": 0, + "type": "INVALID_TYPE", + "pitch": 60, + "velocity": 100, + "timestampMs": 0 + } + ] + } + """; + + // when & then + mockMvc.perform( + post( + "/api/playings/{playingId}/midi-events", + PLAYING_ID + ) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").exists()) + .andExpect(jsonPath("$.message").exists()); + + verifyServiceNotCalled(); + } + + @Test + @DisplayName("MIDI 이벤트가 최대 개수를 초과하면 요청에 실패한다") + void saveMidiEvents_eventCountExceeded() throws Exception { + // given + List events = + new ArrayList<>(MAX_MIDI_EVENT_COUNT + 1); + + for (int sequence = 0; + sequence <= MAX_MIDI_EVENT_COUNT; + sequence++) { + + events.add( + new MidiEventSaveRequest.MidiEventRequest( + sequence, + MidiType.NOTE_ON, + 60, + 100, + 0L ) - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString(request) - ) - ) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.isSuccess").value(true)) - .andExpect( - jsonPath("$.data.playingId") - .value(playingId) - ) - .andExpect( - jsonPath("$.data.savedCount") - .value(2) ); + } - then(playingService) - .should() - .saveMidiEvents( - userId, - playingId, - request - ); + MidiEventSaveRequest request = + new MidiEventSaveRequest(events); + + // when & then + mockMvc.perform( + post( + "/api/playings/{playingId}/midi-events", + PLAYING_ID + ) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString(request) + ) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)); + + verifyServiceNotCalled(); + } + + private void verifyServiceNotCalled() { + then(playingService) + .should(never()) + .saveMidiEvents( + anyLong(), + anyLong(), + any(MidiEventSaveRequest.class) + ); + } } + private MidiEventSaveRequest createRequest() { return new MidiEventSaveRequest( List.of( diff --git a/src/test/java/com/mr/domain/playing/dto/req/MidiEventSaveRequestTest.java b/src/test/java/com/mr/domain/playing/dto/req/MidiEventSaveRequestTest.java new file mode 100644 index 00000000..e0bf8c23 --- /dev/null +++ b/src/test/java/com/mr/domain/playing/dto/req/MidiEventSaveRequestTest.java @@ -0,0 +1,369 @@ +package com.mr.domain.playing.dto.req; + +import com.mr.domain.playing.entity.enums.MidiType; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class MidiEventSaveRequestTest { + + private static ValidatorFactory validatorFactory; + private static Validator validator; + + @BeforeAll + static void setUp() { + validatorFactory = + Validation.buildDefaultValidatorFactory(); + + validator = validatorFactory.getValidator(); + } + + @AfterAll + static void tearDown() { + validatorFactory.close(); + } + + @Test + @DisplayName("유효한 MIDI 이벤트 목록은 검증을 통과한다") + void validateValidRequest() { + // given + MidiEventSaveRequest.MidiEventRequest event = + createValidEvent(); + + MidiEventSaveRequest request = + new MidiEventSaveRequest( + List.of(event) + ); + + // when + Set> violations = + validator.validate(request); + + // then + assertThat(violations) + .isEmpty(); + } + + @Test + @DisplayName("MIDI 이벤트 목록에 null 요소가 있으면 검증에 실패한다") + void validateNullEventInsideList() { + MidiEventSaveRequest request = + new MidiEventSaveRequest( + java.util.Collections.singletonList(null) + ); + + Set> violations = + validator.validate(request); + + assertThat(violations) + .isNotEmpty(); + } + + @Test + @DisplayName("리스트 내부 이벤트의 sequence가 음수이면 검증에 실패한다") + void validateSequenceInsideList() { + // given + MidiEventSaveRequest.MidiEventRequest invalidEvent = + new MidiEventSaveRequest.MidiEventRequest( + -1, + MidiType.NOTE_ON, + 60, + 100, + 100L + ); + + MidiEventSaveRequest request = + new MidiEventSaveRequest( + List.of(invalidEvent) + ); + + // when + Set> violations = + validator.validate(request); + + // then + assertThat(violations) + .anySatisfy(violation -> { + assertThat( + violation.getPropertyPath().toString() + ).contains("events[0].sequence"); + + assertThat(violation.getMessage()) + .isEqualTo( + "MIDI 이벤트 순서 값은 0 이상이어야 합니다." + ); + }); + } + + @Test + @DisplayName("pitch가 음수이면 검증에 실패한다") + void validateNegativePitch() { + MidiEventSaveRequest request = + new MidiEventSaveRequest( + List.of( + new MidiEventSaveRequest.MidiEventRequest( + 0, + MidiType.NOTE_ON, + -1, + 100, + 100L + ) + ) + ); + + Set> violations = + validator.validate(request); + + assertThat(violations) + .extracting(violation -> + violation.getPropertyPath().toString() + ) + .contains("events[0].pitch"); + } + + @Test + @DisplayName("리스트 내부 이벤트의 pitch가 127을 초과하면 검증에 실패한다") + void validatePitchInsideList() { + // given + MidiEventSaveRequest.MidiEventRequest invalidEvent = + new MidiEventSaveRequest.MidiEventRequest( + 0, + MidiType.NOTE_ON, + 128, + 100, + 100L + ); + + MidiEventSaveRequest request = + new MidiEventSaveRequest( + List.of(invalidEvent) + ); + + // when + Set> violations = + validator.validate(request); + + // then + assertThat(violations) + .anySatisfy(violation -> { + assertThat( + violation.getPropertyPath().toString() + ).contains("events[0].pitch"); + + assertThat(violation.getMessage()) + .isEqualTo( + "MIDI 피치 값은 127 이하여야 합니다." + ); + }); + } + + @Test + @DisplayName("리스트 내부 이벤트의 velocity가 음수이면 검증에 실패한다") + void validateVelocityInsideList() { + // given + MidiEventSaveRequest.MidiEventRequest invalidEvent = + new MidiEventSaveRequest.MidiEventRequest( + 0, + MidiType.NOTE_ON, + 60, + -1, + 100L + ); + + MidiEventSaveRequest request = + new MidiEventSaveRequest( + List.of(invalidEvent) + ); + + // when + Set> violations = + validator.validate(request); + + // then + assertThat(violations) + .anySatisfy(violation -> { + assertThat( + violation.getPropertyPath().toString() + ).contains("events[0].velocity"); + + assertThat(violation.getMessage()) + .isEqualTo( + "MIDI 입력 강도 값은 0 이상이어야 합니다." + ); + }); + } + + @Test + @DisplayName("velocity가 127을 초과하면 검증에 실패한다") + void validateExceededVelocity() { + MidiEventSaveRequest request = + new MidiEventSaveRequest( + List.of( + new MidiEventSaveRequest.MidiEventRequest( + 0, + MidiType.NOTE_ON, + 60, + 128, + 100L + ) + ) + ); + + Set> violations = + validator.validate(request); + + assertThat(violations) + .extracting(violation -> + violation.getPropertyPath().toString() + ) + .contains("events[0].velocity"); + } + + @Test + @DisplayName("리스트 내부 이벤트의 timestampMs가 음수이면 검증에 실패한다") + void validateTimestampInsideList() { + // given + MidiEventSaveRequest.MidiEventRequest invalidEvent = + new MidiEventSaveRequest.MidiEventRequest( + 0, + MidiType.NOTE_ON, + 60, + 100, + -1L + ); + + MidiEventSaveRequest request = + new MidiEventSaveRequest( + List.of(invalidEvent) + ); + + // when + Set> violations = + validator.validate(request); + + // then + assertThat(violations) + .anySatisfy(violation -> { + assertThat( + violation.getPropertyPath().toString() + ).contains("events[0].timestampMs"); + + assertThat(violation.getMessage()) + .isEqualTo( + "MIDI 이벤트 발생 시간은 0 이상이어야 합니다." + ); + }); + } + + @Test + @DisplayName("리스트 내부 이벤트의 필수값이 null이면 검증에 실패한다") + void validateNullFieldsInsideList() { + // given + MidiEventSaveRequest.MidiEventRequest invalidEvent = + new MidiEventSaveRequest.MidiEventRequest( + null, + null, + null, + null, + null + ); + + MidiEventSaveRequest request = + new MidiEventSaveRequest( + List.of(invalidEvent) + ); + + // when + Set> violations = + validator.validate(request); + + // then + assertThat(violations) + .extracting( + violation -> + violation.getPropertyPath().toString() + ) + .anyMatch(path -> + path.contains("events[0].sequence") + ) + .anyMatch(path -> + path.contains("events[0].type") + ) + .anyMatch(path -> + path.contains("events[0].pitch") + ) + .anyMatch(path -> + path.contains("events[0].velocity") + ) + .anyMatch(path -> + path.contains("events[0].timestampMs") + ); + } + + @Test + @DisplayName("MIDI 이벤트 목록이 비어 있으면 검증에 실패한다") + void validateEmptyEvents() { + // given + MidiEventSaveRequest request = + new MidiEventSaveRequest( + List.of() + ); + + // when + Set> violations = + validator.validate(request); + + // then + assertThat(violations) + .anySatisfy(violation -> { + assertThat( + violation.getPropertyPath().toString() + ).isEqualTo("events"); + + assertThat(violation.getMessage()) + .isEqualTo( + "MIDI 이벤트 목록은 필수입니다" + ); + }); + } + + @Test + @DisplayName("MIDI 이벤트 목록이 null이면 검증에 실패한다") + void validateNullEvents() { + // given + MidiEventSaveRequest request = + new MidiEventSaveRequest(null); + + // when + Set> violations = + validator.validate(request); + + // then + assertThat(violations) + .anySatisfy(violation -> { + assertThat( + violation.getPropertyPath().toString() + ).isEqualTo("events"); + }); + } + + private MidiEventSaveRequest.MidiEventRequest createValidEvent() { + return new MidiEventSaveRequest.MidiEventRequest( + 0, + MidiType.NOTE_ON, + 60, + 100, + 100L + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/mr/domain/playing/entity/PlayingTest.java b/src/test/java/com/mr/domain/playing/entity/PlayingTest.java new file mode 100644 index 00000000..f82df7bb --- /dev/null +++ b/src/test/java/com/mr/domain/playing/entity/PlayingTest.java @@ -0,0 +1,596 @@ +package com.mr.domain.playing.entity; + +import com.mr.domain.backingTrack.entity.BackingTrack; +import com.mr.domain.playing.entity.enums.MidiType; +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.global.apipayload.exception.GeneralException; +import com.mr.domain.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static com.mr.domain.playing.constant.MidiEventConstants.MAX_MIDI_EVENT_COUNT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PlayingTest { + + private static final int BPM = 120; + + @Test + @DisplayName("READY 상태의 연주를 시작하면 IN_PROGRESS 상태로 변경된다") + void startPlaying() { + // given + Playing playing = createReadyPlaying(); + + // when + playing.start(); + + // then + assertThat(playing.getStatus()) + .isEqualTo(PlayingStatus.IN_PROGRESS); + + assertThat(playing.getStartedAt()) + .isNotNull(); + } + + @Test + @DisplayName("진행 중인 연주에 MIDI 데이터를 저장하면 완료 상태로 변경된다") + void completeWithMidiData() { + // given + Playing playing = createInProgressPlaying(); + + List midiData = List.of( + createMidiEvent(0, 100L), + createMidiEvent(1, 200L), + createMidiEvent(2, 300L) + ); + + // when + playing.completeWithMidiData(midiData); + + // then + assertThat(playing.getMidiData()) + .hasSize(3); + + assertThat(playing.getEndedAt()) + .isNotNull(); + + assertThat(playing.getDurationSec()) + .isNotNull() + .isGreaterThanOrEqualTo(0) + .isLessThanOrEqualTo(600); + + assertThat(playing.getStatus()) + .isEqualTo(PlayingStatus.COMPLETED); + } + + @Test + @DisplayName("MIDI 이벤트는 timestampMs와 sequence 오름차순으로 저장된다") + void sortMidiData() { + // given + Playing playing = createInProgressPlaying(); + + List midiData = List.of( + createMidiEvent(3, 300L), + createMidiEvent(2, 100L), + createMidiEvent(1, 100L), + createMidiEvent(4, 200L) + ); + + // when + playing.completeWithMidiData(midiData); + + // then + assertThat(playing.getMidiData()) + .extracting( + MidiEventData::getTimestampMs, + MidiEventData::getSequence + ) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(100L, 1), + org.assertj.core.groups.Tuple.tuple(100L, 2), + org.assertj.core.groups.Tuple.tuple(200L, 4), + org.assertj.core.groups.Tuple.tuple(300L, 3) + ); + } + + @Test + @DisplayName("MIDI 이벤트 목록이 비어 있으면 EMPTY_MIDI_EVENTS 예외가 발생한다") + void rejectEmptyMidiData() { + Playing playing = createInProgressPlaying(); + + assertThatThrownBy(() -> + playing.completeWithMidiData(List.of()) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + MidiEventErrorStatus.EMPTY_MIDI_EVENTS + ); + }); + } + + @Test + @DisplayName("MIDI 이벤트 목록이 null이면 EMPTY_MIDI_EVENTS 예외가 발생한다") + void rejectNullMidiData() { + // given + Playing playing = createInProgressPlaying(); + + // when & then + assertThatThrownBy(() -> + playing.completeWithMidiData(null) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + MidiEventErrorStatus.EMPTY_MIDI_EVENTS + ); + }); + } + + @Test + @DisplayName("MIDI 이벤트 목록에 null 요소가 있으면 INVALID_MIDI_EVENT 예외가 발생한다") + void rejectNullMidiEvent() { + // given + Playing playing = createInProgressPlaying(); + + List midiData = new ArrayList<>(); + midiData.add(createMidiEvent(0, 100L)); + midiData.add(null); + + // when & then + assertThatThrownBy(() -> + playing.completeWithMidiData(midiData) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + MidiEventErrorStatus.INVALID_MIDI_EVENT + ); + }); + } + + @Test + @DisplayName("timestampMs와 sequence가 모두 같으면 DUPLICATE_MIDI_SEQUENCE 예외가 발생한다") + void rejectDuplicateMidiEventOrder() { + // given + Playing playing = createInProgressPlaying(); + + List midiData = List.of( + createMidiEvent(1, 100L), + createMidiEvent(1, 100L) + ); + + // when & then + assertThatThrownBy(() -> + playing.completeWithMidiData(midiData) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + MidiEventErrorStatus.DUPLICATE_MIDI_SEQUENCE + ); + }); + } + + @Test + @DisplayName("sequence가 같아도 timestampMs가 다르면 저장할 수 있다") + void acceptSameSequenceWithDifferentTimestamp() { + // given + Playing playing = createInProgressPlaying(); + + List midiData = List.of( + createMidiEvent(1, 100L), + createMidiEvent(1, 200L) + ); + + // when + playing.completeWithMidiData(midiData); + + // then + assertThat(playing.getMidiData()) + .hasSize(2); + } + + @Test + @DisplayName("MIDI 이벤트가 최대 개수이면 저장할 수 있다") + void acceptMaximumMidiEventCount() { + // given + Playing playing = createInProgressPlaying(); + + List midiData = + new ArrayList<>(MAX_MIDI_EVENT_COUNT); + + for (int sequence = 0; + sequence < MAX_MIDI_EVENT_COUNT; + sequence++) { + + midiData.add( + createMidiEvent(sequence, 0L) + ); + } + + // when + playing.completeWithMidiData(midiData); + + // then + assertThat(playing.getMidiData()) + .hasSize(MAX_MIDI_EVENT_COUNT); + + assertThat(playing.getStatus()) + .isEqualTo(PlayingStatus.COMPLETED); + } + + @Test + @DisplayName("MIDI 이벤트 최대 개수를 초과하면 EXCEEDED_MIDI_EVENT_COUNT 예외가 발생한다") + void rejectExceededMidiEventCount() { + // given + Playing playing = createInProgressPlaying(); + + List midiData = + new ArrayList<>(MAX_MIDI_EVENT_COUNT + 1); + + for (int sequence = 0; + sequence <= MAX_MIDI_EVENT_COUNT; + sequence++) { + + midiData.add( + createMidiEvent(sequence, 0L) + ); + } + + // when & then + assertThatThrownBy(() -> + playing.completeWithMidiData(midiData) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + MidiEventErrorStatus.EXCEEDED_MIDI_EVENT_COUNT + ); + }); + } + + @Test + @DisplayName("완료된 연주에는 MIDI 데이터를 다시 저장할 수 없다") + void rejectCompletedPlaying() { + // given + Playing playing = createInProgressPlaying(); + + playing.completeWithMidiData( + List.of(createMidiEvent(0, 0L)) + ); + + // when & then + assertThatThrownBy(() -> + playing.completeWithMidiData( + List.of(createMidiEvent(1, 100L)) + ) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + MidiEventErrorStatus.PLAYING_NOT_IN_PROGRESS + ); + }); + } + + @Test + @DisplayName("허용 시간을 초과한 MIDI 이벤트만 존재하면 완료 처리하지 않는다") + void rejectMidiDataEmptyAfterNormalization() { + // given + Playing playing = createInProgressPlaying(); + + List midiData = + List.of( + createMidiEvent( + 0, + 600_001L + ) + ); + + // when & then + assertThatThrownBy(() -> + playing.completeWithMidiData(midiData) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + MidiEventErrorStatus.EMPTY_MIDI_EVENTS + ); + }); + + assertThat(playing.getStatus()) + .isEqualTo(PlayingStatus.IN_PROGRESS); + + assertThat(playing.getMidiData()) + .isEmpty(); + + assertThat(playing.getEndedAt()) + .isNull(); + + assertThat(playing.getDurationSec()) + .isNull(); + } + + @Test + @DisplayName("READY 상태에서는 PLAYING_NOT_IN_PROGRESS 예외가 발생한다") + void rejectReadyPlaying() { + // given + Playing playing = createReadyPlaying(); + + // when & then + assertThatThrownBy(() -> + playing.completeWithMidiData( + List.of(createMidiEvent(0, 0L)) + ) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + MidiEventErrorStatus.PLAYING_NOT_IN_PROGRESS + ); + }); + } + + @Test + @DisplayName("연주 생성 시 BPM이 null이면 기본값 120이 적용된다") + void applyDefaultBpm() { + // given + User user = mock(User.class); + BackingTrack backingTrack = mock(BackingTrack.class); + + // when + Playing playing = Playing.createBackingTrack( + user, + backingTrack, + null + ); + + // then + assertThat(playing.getBpm()) + .isEqualTo(120); + } + + @Test + @DisplayName("연주 생성 시 BPM이 허용 범위를 벗어나면 INVALID_BPM_RANGE 예외가 발생한다") + void rejectInvalidBpm() { + // given + User user = mock(User.class); + BackingTrack backingTrack = mock(BackingTrack.class); + + // when & then + assertThatThrownBy(() -> + Playing.createBackingTrack( + user, + backingTrack, + 201 + ) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + PlayingErrorStatus.INVALID_BPM_RANGE + ); + }); + } + + @Test + @DisplayName("원본 MIDI 목록을 변경해도 엔티티에 저장된 목록은 변경되지 않는다") + void copyMidiDataDefensively() { + // given + Playing playing = createInProgressPlaying(); + + List midiData = new ArrayList<>(); + midiData.add(createMidiEvent(0, 0L)); + midiData.add(createMidiEvent(1, 100L)); + + // when + playing.completeWithMidiData(midiData); + midiData.clear(); + + // then + assertThat(playing.getMidiData()) + .hasSize(2); + } + + @Test + @DisplayName("getMidiData로 반환된 목록은 수정할 수 없다") + void returnUnmodifiableMidiData() { + // given + Playing playing = createInProgressPlaying(); + + playing.completeWithMidiData( + List.of(createMidiEvent(0, 0L)) + ); + + List savedMidiData = + playing.getMidiData(); + + // when & then + assertThatThrownBy( + () -> savedMidiData.add( + createMidiEvent(1, 100L) + ) + ) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + @DisplayName("사용자가 null이면 MISSING_USER_ID 예외가 발생한다") + void rejectNullUser() { + BackingTrack backingTrack = + mock(BackingTrack.class); + + assertThatThrownBy(() -> + Playing.createBackingTrack( + null, + backingTrack, + BPM + ) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + PlayingErrorStatus.MISSING_USER_ID + ); + }); + } + + @Test + @DisplayName("연주 소유자의 사용자 ID가 일치하면 검증을 통과한다") + void validatePlayingOwner_success() { + User user = mock(User.class); + BackingTrack backingTrack = + mock(BackingTrack.class); + + when(user.getUserId()) + .thenReturn(1L); + + Playing playing = + Playing.createBackingTrack( + user, + backingTrack, + BPM + ); + + assertThatCode(() -> + playing.validatePlayingOwner(1L) + ).doesNotThrowAnyException(); + } + + @Test + @DisplayName("연주 소유자의 사용자 ID가 다르면 접근 예외가 발생한다") + void validatePlayingOwner_accessDenied() { + User user = mock(User.class); + BackingTrack backingTrack = + mock(BackingTrack.class); + + when(user.getUserId()) + .thenReturn(1L); + + Playing playing = + Playing.createBackingTrack( + user, + backingTrack, + BPM + ); + + assertThatThrownBy(() -> + playing.validatePlayingOwner(2L) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + PlayingErrorStatus.PLAYING_ACCESS_DENIED + ); + }); + } + + @Test + @DisplayName("백킹트랙이 null이면 MISSING_BACKING_TRACK_ID 예외가 발생한다") + void rejectNullBackingTrack() { + User user = mock(User.class); + + assertThatThrownBy(() -> + Playing.createBackingTrack( + user, + null, + BPM + ) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + PlayingErrorStatus.MISSING_BACKING_TRACK_ID + ); + }); + } + + private Playing createReadyPlaying() { + User user = mock(User.class); + BackingTrack backingTrack = mock(BackingTrack.class); + + return Playing.createBackingTrack( + user, + backingTrack, + BPM + ); + } + + private Playing createInProgressPlaying() { + Playing playing = createReadyPlaying(); + playing.start(); + + return playing; + } + + private MidiEventData createMidiEvent( + int sequence, + long timestampMs + ) { + return MidiEventData.of( + sequence, + MidiType.NOTE_ON, + 60, + 100, + timestampMs + ); + } +} \ No newline at end of file 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 4813cd90..8c4ff4fb 100644 --- a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java +++ b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java @@ -5,6 +5,7 @@ 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.PlayingStatus; import com.mr.domain.playing.exception.MidiEventErrorStatus; import com.mr.domain.playing.exception.PlayingErrorStatus; import com.mr.domain.playing.repository.PlayingRepository; @@ -18,17 +19,22 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.LocalDateTime; 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.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.Mockito.verify; import static org.mockito.Mockito.when; + @ExtendWith(MockitoExtension.class) class PlayingServiceTest { @@ -60,7 +66,7 @@ void saveMidiEvents_success() { // given MidiEventSaveRequest request = createRequest(); - when(playingRepository.findById(playingId)) + when(playingRepository.findByIdAndDeletedAtIsNull(playingId)) .thenReturn(Optional.of(playing)); when(playing.getId()) @@ -97,22 +103,113 @@ void saveMidiEvents_success() { .isEqualTo(2); verify(playingRepository) - .findById(playingId); + .findByIdAndDeletedAtIsNull(playingId); verify(playing) - .validateOwner(userId); + .validatePlayingOwner(userId); verify(playing) .completeWithMidiData(anyList()); } + @Test + @DisplayName("본인의 연주가 아니면 MIDI 이벤트를 저장하지 않는다") + void saveMidiEvents_accessDenied() { + MidiEventSaveRequest request = + createRequest(); + + when( + playingRepository + .findByIdAndDeletedAtIsNull(playingId) + ) + .thenReturn(Optional.of(playing)); + + doThrow( + new GeneralException( + PlayingErrorStatus.PLAYING_ACCESS_DENIED + ) + ) + .when(playing) + .validatePlayingOwner(userId); + + assertThatThrownBy(() -> + playingService.saveMidiEvents( + userId, + playingId, + request + ) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + PlayingErrorStatus.PLAYING_ACCESS_DENIED + ); + }); + + verify(playing, never()) + .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() { // given MidiEventSaveRequest request = createRequest(); - when(playingRepository.findById(playingId)) + when(playingRepository.findByIdAndDeletedAtIsNull(playingId)) .thenReturn(Optional.of(playing)); when(playing.getId()) @@ -160,6 +257,45 @@ void saveMidiEvents_convertsRequestToEntity() { .completeWithMidiData(anyList()); } + @Test + @DisplayName("진행 중이 아닌 연주에는 MIDI 이벤트를 저장할 수 없다") + void saveMidiEvents_notInProgress() { + MidiEventSaveRequest request = + createRequest(); + + when( + playingRepository + .findByIdAndDeletedAtIsNull(playingId) + ) + .thenReturn(Optional.of(playing)); + + doThrow( + new GeneralException( + MidiEventErrorStatus.PLAYING_NOT_IN_PROGRESS + ) + ) + .when(playing) + .completeWithMidiData(anyList()); + + assertThatThrownBy(() -> + playingService.saveMidiEvents( + userId, + playingId, + request + ) + ) + .isInstanceOf(GeneralException.class) + .satisfies(exception -> { + GeneralException generalException = + (GeneralException) exception; + + assertThat(generalException.getCode()) + .isEqualTo( + MidiEventErrorStatus.PLAYING_NOT_IN_PROGRESS + ); + }); + } + @Test @DisplayName("playingId가 null이면 예외가 발생한다") void saveMidiEvents_nullPlayingId() { @@ -187,7 +323,7 @@ void saveMidiEvents_nullPlayingId() { }); verify(playingRepository, never()) - .findById(org.mockito.ArgumentMatchers.any()); + .findByIdAndDeletedAtIsNull(any()); } @Test @@ -217,7 +353,7 @@ void saveMidiEvents_invalidPlayingId() { }); verify(playingRepository, never()) - .findById(org.mockito.ArgumentMatchers.any()); + .findByIdAndDeletedAtIsNull(any()); } @Test @@ -226,7 +362,7 @@ void saveMidiEvents_playingNotFound() { // given MidiEventSaveRequest request = createRequest(); - when(playingRepository.findById(playingId)) + when(playingRepository.findByIdAndDeletedAtIsNull(playingId)) .thenReturn(Optional.empty()); // when & then @@ -250,7 +386,7 @@ void saveMidiEvents_playingNotFound() { }); verify(playing, never()) - .validateOwner(userId); + .validatePlayingOwner(userId); verify(playing, never()) .completeWithMidiData(anyList()); From f22c79554cfc46286f96712224686cc697779fed Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Thu, 30 Jul 2026 22:29:23 +0900 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20=EC=97=B0=EC=A3=BC=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EA=B6=8C=ED=95=9C=20=EC=98=A4=EB=A5=98=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20403=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/playing/exception/PlayingErrorStatus.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 473e3ef4..c5379fb1 100644 --- a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java +++ b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java @@ -18,11 +18,11 @@ public enum PlayingErrorStatus implements BaseCode { MISSING_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_11", "연주 모드는 필수 입력값입니다."), 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", "해당 연주에 대한 접근 권한이 없습니다."), PLAYING_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAYING_404_01", "연주 세션을 찾을 수 없습니다."), 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", "연주 종료 시간이 시작 시간보다 이전일 수 없습니다."), - PLAYING_ACCESS_DENIED(HttpStatus.FORBIDDEN, "PLAYING_409_04", "해당 연주에 대한 접근 권한이 없습니다."), ;