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/controller/PlayingController.java b/src/main/java/com/mr/domain/playing/controller/PlayingController.java new file mode 100644 index 00000000..80169b39 --- /dev/null +++ b/src/main/java/com/mr/domain/playing/controller/PlayingController.java @@ -0,0 +1,45 @@ +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 io.swagger.v3.oas.annotations.Operation; +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; + + @Operation( + summary = "MIDI 이벤트 저장", + description = "연주 세션에 대한 MIDI 이벤트를 저장하고 연주를 완료 상태로 변경합니다." + ) + @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); + } +} 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..bfde1802 --- /dev/null +++ b/src/main/java/com/mr/domain/playing/dto/req/MidiEventSaveRequest.java @@ -0,0 +1,45 @@ +package com.mr.domain.playing.dto.req; + +import com.mr.domain.playing.entity.enums.MidiType; +import jakarta.validation.Valid; +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 = MAX_MIDI_EVENT_COUNT, message = "MIDI 이벤트는 최대 100,000개까지 저장할 수 있습니다.") + 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/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 75f3a59b..82332ddd 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; @@ -36,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", @@ -55,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) @@ -187,42 +189,32 @@ 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(); + this.midiData = new ArrayList<>(normalizedMidiData); + this.endedAt = calculateEndedAt(completedAt); + this.durationSec = convertToDurationSec(savedDurationMs); + this.status = PlayingStatus.COMPLETED; + } - if (sortedMidiData.isEmpty()) { - throw new GeneralException( - PlayingErrorStatus.EMPTY_MIDI_EVENTS - ); + public void validatePlayingOwner(Long userId) { + if (userId == null || this.user == null || !Objects.equals(this.user.getUserId(), userId)) { + throw new GeneralException(PlayingErrorStatus.PLAYING_ACCESS_DENIED); } - - 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.status = PlayingStatus.COMPLETED; } 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) { @@ -232,15 +224,19 @@ 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); + } + + if (midiData.stream().anyMatch(Playing::hasInvalidEventOrder)) { + throw new GeneralException(MidiEventErrorStatus.INVALID_MIDI_EVENT); } Set seenOrders = new HashSet<>(); @@ -253,12 +249,22 @@ private static void validateMidiData(List midiData) { if (!seenOrders.add(order)) { throw new GeneralException( - PlayingErrorStatus.DUPLICATE_MIDI_SEQUENCE + MidiEventErrorStatus.DUPLICATE_MIDI_SEQUENCE ); } } } + private static void validateNormalizedMidiData( + List normalizedMidiData + ) { + if (normalizedMidiData.isEmpty()) { + throw new GeneralException( + MidiEventErrorStatus.EMPTY_MIDI_EVENTS + ); + } + } + private record MidiEventOrder( Long timestampMs, Integer sequence @@ -277,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) { @@ -297,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; + } } 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..4a3a0b30 --- /dev/null +++ b/src/main/java/com/mr/domain/playing/exception/MidiEventErrorStatus.java @@ -0,0 +1,30 @@ +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 값이 존재합니다."), + + 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; + 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..c5379fb1 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,12 @@ 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 이벤트 목록에 유효하지 않은 값이 포함되어 있습니다."), + // 기존 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", "현재 지원하지 않는 연주 모드입니다."), - 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 값이 존재합니다."), - + 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", "연주 종료 시간이 시작 시간보다 이전일 수 없습니다."), 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 cbb2fbd6..590d44b1 100644 --- a/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java +++ b/src/main/java/com/mr/domain/playing/repository/PlayingRepository.java @@ -85,4 +85,12 @@ interface PracticeTotals { Long getTotalDurationSec(); LocalDateTime getLastEndedAt(); } + + 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 new file mode 100644 index 00000000..cf0afb8e --- /dev/null +++ b/src/main/java/com/mr/domain/playing/service/PlayingService.java @@ -0,0 +1,85 @@ +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.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.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PlayingService { + + private static final long MIDI_SAVE_REQUEST_INTERVAL_MINUTES = 1L; + private final PlayingRepository playingRepository; + + @Transactional + public MidiEventSaveResponse saveMidiEvents( + Long userId, Long playingId, MidiEventSaveRequest request + ) { + validatePlayingId(playingId); + + Playing playing = playingRepository.findByIdAndDeletedAtIsNull(playingId) + .orElseThrow(() -> new GeneralException(PlayingErrorStatus.PLAYING_NOT_FOUND)); + + playing.validatePlayingOwner(userId); + validateMidiSaveRequestInterval(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); + } + } + + 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/controller/PlayingControllerTest.java b/src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java new file mode 100644 index 00000000..02b32467 --- /dev/null +++ b/src/test/java/com/mr/domain/playing/controller/PlayingControllerTest.java @@ -0,0 +1,344 @@ +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.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; +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 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; + +@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; + + @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(); + } + + @Nested + @DisplayName("MIDI 이벤트 저장 성공") + class SaveMidiEventsSuccess { + @Test + @DisplayName("POST /api/playings/{playingId}/midi-events - MIDI 이벤트 저장 성공") + void saveMidiEvents_success() throws Exception { + // given + + MidiEventSaveRequest request = createRequest(); + + MidiEventSaveResponse response = + new MidiEventSaveResponse( + PLAYING_ID, + 2 + ); + + 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) + ); + + 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 + ) + ); + } + + 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( + 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/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 new file mode 100644 index 00000000..8c4ff4fb --- /dev/null +++ b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java @@ -0,0 +1,417 @@ +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.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.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.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 { + + @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.findByIdAndDeletedAtIsNull(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) + .findByIdAndDeletedAtIsNull(playingId); + + verify(playing) + .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.findByIdAndDeletedAtIsNull(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("진행 중이 아닌 연주에는 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() { + // 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()) + .findByIdAndDeletedAtIsNull(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()) + .findByIdAndDeletedAtIsNull(any()); + } + + @Test + @DisplayName("연주가 존재하지 않으면 예외가 발생한다") + void saveMidiEvents_playingNotFound() { + // given + MidiEventSaveRequest request = createRequest(); + + when(playingRepository.findByIdAndDeletedAtIsNull(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()) + .validatePlayingOwner(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