diff --git a/src/main/java/kr/ac/chungbuk/harmonize/controller/MusicController.java b/src/main/java/kr/ac/chungbuk/harmonize/controller/MusicController.java index 33f8143..70834a5 100644 --- a/src/main/java/kr/ac/chungbuk/harmonize/controller/MusicController.java +++ b/src/main/java/kr/ac/chungbuk/harmonize/controller/MusicController.java @@ -19,7 +19,6 @@ import kr.ac.chungbuk.harmonize.utility.FileHandler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.MessageSource; import org.springframework.core.io.FileSystemResource; import org.springframework.data.domain.*; import org.springframework.data.web.PageableDefault; diff --git a/src/main/java/kr/ac/chungbuk/harmonize/entity/MusicAnalysis.java b/src/main/java/kr/ac/chungbuk/harmonize/entity/MusicAnalysis.java index 9258395..a600be4 100644 --- a/src/main/java/kr/ac/chungbuk/harmonize/entity/MusicAnalysis.java +++ b/src/main/java/kr/ac/chungbuk/harmonize/entity/MusicAnalysis.java @@ -49,5 +49,10 @@ public MusicAnalysis(Long musicId) { this.status = Status.INCOMPLETE; } + public MusicAnalysis(Long musicId, Status status) { + this.musicId = musicId; + this.status = status; + } + public MusicAnalysis() {} } diff --git a/src/main/java/kr/ac/chungbuk/harmonize/entity/User.java b/src/main/java/kr/ac/chungbuk/harmonize/entity/User.java index c8268ab..f3b47db 100644 --- a/src/main/java/kr/ac/chungbuk/harmonize/entity/User.java +++ b/src/main/java/kr/ac/chungbuk/harmonize/entity/User.java @@ -4,6 +4,7 @@ import kr.ac.chungbuk.harmonize.enums.Gender; import kr.ac.chungbuk.harmonize.enums.Genre; import kr.ac.chungbuk.harmonize.enums.Role; +import lombok.Builder; import lombok.Getter; import lombok.Setter; import org.springframework.security.core.GrantedAuthority; @@ -73,6 +74,7 @@ public class User implements UserDetails { public User() { } + @Builder public User(String loginId, String password, String email, String nickname, Role role, Gender gender, Integer age) { this.loginId = loginId; this.password = password; diff --git a/src/main/java/kr/ac/chungbuk/harmonize/service/MusicActionService.java b/src/main/java/kr/ac/chungbuk/harmonize/service/MusicActionService.java index c6080a3..985feb2 100644 --- a/src/main/java/kr/ac/chungbuk/harmonize/service/MusicActionService.java +++ b/src/main/java/kr/ac/chungbuk/harmonize/service/MusicActionService.java @@ -1,23 +1,26 @@ package kr.ac.chungbuk.harmonize.service; -import jakarta.transaction.Transactional; import kr.ac.chungbuk.harmonize.entity.Bookmark; import kr.ac.chungbuk.harmonize.entity.Music; import kr.ac.chungbuk.harmonize.entity.User; import kr.ac.chungbuk.harmonize.repository.BookmarkRepository; import kr.ac.chungbuk.harmonize.repository.MusicRepository; import kr.ac.chungbuk.harmonize.repository.UserRepository; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.NoSuchElementException; -@Service @Slf4j +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service // 북마크(좋아요), 추천 평가 등 음악과 관련된 사용자 행위를 담당하는 서비스 public class MusicActionService { @@ -25,14 +28,6 @@ public class MusicActionService { private final MusicRepository musicRepository; private final BookmarkRepository bookmarkRepository; - @Autowired - public MusicActionService(UserRepository userRepository, MusicRepository musicRepository, - BookmarkRepository bookmarkRepository) { - this.userRepository = userRepository; - this.musicRepository = musicRepository; - this.bookmarkRepository = bookmarkRepository; - } - // 북마크(좋아요 버튼) @Transactional public void createBookmark(Long userId, Long musicId) { diff --git a/src/main/java/kr/ac/chungbuk/harmonize/service/MusicAnalysisService.java b/src/main/java/kr/ac/chungbuk/harmonize/service/MusicAnalysisService.java index e1cfe79..eacdb90 100644 --- a/src/main/java/kr/ac/chungbuk/harmonize/service/MusicAnalysisService.java +++ b/src/main/java/kr/ac/chungbuk/harmonize/service/MusicAnalysisService.java @@ -1,6 +1,5 @@ package kr.ac.chungbuk.harmonize.service; -import jakarta.transaction.Transactional; import kr.ac.chungbuk.harmonize.entity.Music; import kr.ac.chungbuk.harmonize.entity.User; import kr.ac.chungbuk.harmonize.enums.Status; @@ -9,16 +8,17 @@ import kr.ac.chungbuk.harmonize.utility.FileHandler; import lombok.RequiredArgsConstructor; import org.apache.tomcat.util.http.fileupload.impl.SizeLimitExceededException; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.kafka.requestreply.ReplyingKafkaTemplate; import org.springframework.kafka.requestreply.RequestReplyMessageFuture; import org.springframework.kafka.support.KafkaHeaders; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import org.springframework.messaging.support.MessageBuilder; import java.io.*; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutionException; @@ -79,7 +79,7 @@ public void updateAlbumCover(MultipartFile albumCover) throws Exception { // 음악 파일 업로드 (벌크 업로드) @Transactional - public void updateAudioFile(MultipartFile audioFile) throws Exception { + public void updateAudioFile(MultipartFile audioFile) throws IOException { String originalFilename = audioFile.getOriginalFilename(); assert originalFilename != null; @@ -118,7 +118,7 @@ private void saveLyric(MultipartFile lyricFile, Music music) throws IOException, } InputStream stream = lyricFile.getInputStream(); - BufferedReader reader = new BufferedReader(new InputStreamReader(stream)); + BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8)); String lyric = reader.lines().collect(Collectors.joining("\n")); music.setLyrics(lyric); } diff --git a/src/test/java/kr/ac/chungbuk/harmonize/ControllerTestSupport.java b/src/test/java/kr/ac/chungbuk/harmonize/ControllerTestSupport.java new file mode 100644 index 0000000..76f1b5c --- /dev/null +++ b/src/test/java/kr/ac/chungbuk/harmonize/ControllerTestSupport.java @@ -0,0 +1,59 @@ +package kr.ac.chungbuk.harmonize; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.ac.chungbuk.harmonize.config.SecurityConfig; +import kr.ac.chungbuk.harmonize.controller.MusicController; +import kr.ac.chungbuk.harmonize.repository.UserRepository; +import kr.ac.chungbuk.harmonize.security.JwtAuthenticationEntryPoint; +import kr.ac.chungbuk.harmonize.security.JwtAuthenticationFilter; +import kr.ac.chungbuk.harmonize.service.LogService; +import kr.ac.chungbuk.harmonize.service.MusicActionService; +import kr.ac.chungbuk.harmonize.service.MusicService; +import kr.ac.chungbuk.harmonize.utility.FileHandler; +import org.mockito.Mock; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.mock; + +@WebMvcTest(controllers = { + MusicController.class +}) +@Import(SecurityConfig.class) +public abstract class ControllerTestSupport { + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + @MockBean + protected UserRepository userRepository; + + @MockBean + protected MusicService musicService; + + @MockBean + protected MusicActionService musicActionService; + + @MockBean + protected LogService logService; + + @MockBean + protected FileHandler fileHandler; + + @Autowired + private JwtAuthenticationFilter jwtAuthenticationFilter; + + @MockBean + private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; +} diff --git a/src/test/java/kr/ac/chungbuk/harmonize/HarmonizeApplicationTests.java b/src/test/java/kr/ac/chungbuk/harmonize/HarmonizeApplicationTests.java deleted file mode 100644 index a24fc75..0000000 --- a/src/test/java/kr/ac/chungbuk/harmonize/HarmonizeApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package kr.ac.chungbuk.harmonize; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class HarmonizeApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/kr/ac/chungbuk/harmonize/IntegrationTestSupport.java b/src/test/java/kr/ac/chungbuk/harmonize/IntegrationTestSupport.java new file mode 100644 index 0000000..6522c76 --- /dev/null +++ b/src/test/java/kr/ac/chungbuk/harmonize/IntegrationTestSupport.java @@ -0,0 +1,21 @@ +package kr.ac.chungbuk.harmonize; + +import kr.ac.chungbuk.harmonize.config.KafkaTopicConfig; +import kr.ac.chungbuk.harmonize.config.ScheduledTask; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.MockBeans; +import org.springframework.kafka.requestreply.ReplyingKafkaTemplate; + +@SpringBootTest +@MockBeans({ + @MockBean(KafkaTopicConfig.class), + @MockBean(ReplyingKafkaTemplate.class), + @MockBean(ScheduledTask.class) +}) +public class IntegrationTestSupport { + + @MockBean + protected ReplyingKafkaTemplate kafkaTemplate; + +} diff --git a/src/test/java/kr/ac/chungbuk/harmonize/controller/MusicControllerOldTest.java b/src/test/java/kr/ac/chungbuk/harmonize/controller/MusicControllerOldTest.java new file mode 100644 index 0000000..8674f77 --- /dev/null +++ b/src/test/java/kr/ac/chungbuk/harmonize/controller/MusicControllerOldTest.java @@ -0,0 +1,156 @@ +package kr.ac.chungbuk.harmonize.controller; + +import kr.ac.chungbuk.harmonize.entity.Music; +import kr.ac.chungbuk.harmonize.enums.Genre; +import kr.ac.chungbuk.harmonize.repository.MusicRepository; +import kr.ac.chungbuk.harmonize.service.MusicService; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.io.FileInputStream; +import java.io.IOException; +import java.net.URI; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@WithMockUser(authorities = "ADMIN") +class MusicControllerOldTest { + + @Autowired + private MockMvc mvc; + + @Autowired + private MusicService musicService; + @Autowired + private MusicRepository musicRepository; + + @BeforeEach + void setUp() throws Exception { + // Given + MockMultipartFile albumCover = getProfileImage(); + + mvc.perform( + multipart("/api/music") + .file("albumCover", albumCover.getBytes()) + .param("title", "<테스트 노래명>") + .param("genre", "INDIE") + .param("karaokeNum", "TJ 53651") + .param("releaseDate", "2019-03-13T00:00:00") + .param("playLink", "https://youtu.be/1gmleC0dOYY?si=ZFejSnIAzEEZx7Xd") + .param("themes", "부드러운 목소리, 테스트 테마!") + ).andExpect(status().isCreated()); + + mvc.perform( + multipart("/api/music") + .file("albumCover", albumCover.getBytes()) + .param("title", "<테스트 노래명2>") + .param("genre", "INDIE") + .param("karaokeNum", "TJ 53651") + .param("releaseDate", "2019-03-13T00:00:00") + .param("playLink", "https://youtu.be/1gmleC0dOYY?si=ZFejSnIAzEEZx7Xd") + ).andExpect(status().isCreated()); + } + + @AfterEach + void cleanUp() throws Exception { + Optional uploaded1 = musicRepository.findByTitle("<테스트 노래명>"); + if (uploaded1.isPresent()) + musicService.delete(uploaded1.get().getMusicId()); + Optional uploaded2 = musicRepository.findByTitle("<테스트 노래명2>"); + if (uploaded2.isPresent()) + musicService.delete(uploaded2.get().getMusicId()); + } + + @Test + void create() throws Exception { + // When & Then + Music uploaded = musicRepository.findByTitle("<테스트 노래명>").orElseThrow(); + } + + @Test + void update() throws Exception { + // Given + Long musicId = musicRepository.findByTitle("<테스트 노래명>").orElseThrow().getMusicId(); + + // When + mvc.perform(put(new URI("/api/music/"+musicId)) + .param("title", "<테스트 노래명>") + .param("genre", "ROCK")) + .andExpect(status().isAccepted()); + + // Then + Music music = musicRepository.findByTitle("<테스트 노래명>").orElseThrow(); + assertThat(music.getGenre() == Genre.ROCK); + } + + @Test + void delete() throws Exception { + // When + Music uploaded = musicRepository.findByTitle("<테스트 노래명>").orElseThrow(); + + mvc.perform(MockMvcRequestBuilders.delete(new URI("/api/music/" + uploaded.getMusicId()))) + .andExpect(status().isAccepted()); + + // Then + assertThat(musicRepository.findByTitle("<테스트 노래명>").isEmpty()); + } + + @Test + void list() throws Exception { + // When & Then + mvc.perform(get(new URI("/api/music?page=0&size=2"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content", Matchers.hasSize(2))) + .andExpect(jsonPath("$.content[0].title").value(Matchers.is("<테스트 노래명2>"))) + .andExpect(jsonPath("$.content[1].title").value(Matchers.is("<테스트 노래명>"))); + } + + @Test + void listThemes() throws Exception { + // When & Then + mvc.perform(get(new URI("/api/music/theme"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()", Matchers.greaterThanOrEqualTo(2))); + } + + @Test + void listMusicOfTheme() throws Exception { + // When & Then + mvc.perform(get(new URI("/api/music/theme/music")).param("themeName", "테스트 테마!")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].themes").isArray()) + .andExpect(jsonPath("$.content[0].themes").value(Matchers.hasItem("테스트 테마!"))); + } + + + private MockMultipartFile getProfileImage() throws IOException { + final String filename = "albumcover.jpg"; + final String filePath = "src/test/resources/" + filename; + FileInputStream fileInputStream = new FileInputStream(filePath); + + MockMultipartFile profileImage = new MockMultipartFile( + "images", + filename, + "jpg", + fileInputStream + ); + return profileImage; + } +} \ No newline at end of file diff --git a/src/test/java/kr/ac/chungbuk/harmonize/controller/MusicControllerTest.java b/src/test/java/kr/ac/chungbuk/harmonize/controller/MusicControllerTest.java index 3c169b5..8f5fe7c 100644 --- a/src/test/java/kr/ac/chungbuk/harmonize/controller/MusicControllerTest.java +++ b/src/test/java/kr/ac/chungbuk/harmonize/controller/MusicControllerTest.java @@ -1,156 +1,88 @@ package kr.ac.chungbuk.harmonize.controller; -import kr.ac.chungbuk.harmonize.entity.Music; -import kr.ac.chungbuk.harmonize.enums.Genre; -import kr.ac.chungbuk.harmonize.repository.MusicRepository; -import kr.ac.chungbuk.harmonize.service.MusicService; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; +import kr.ac.chungbuk.harmonize.ControllerTestSupport; +import kr.ac.chungbuk.harmonize.controller.annotation.WithMockCustomUser; +import kr.ac.chungbuk.harmonize.enums.Role; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; -import java.io.FileInputStream; -import java.io.IOException; -import java.net.URI; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@SpringBootTest -@AutoConfigureMockMvc -@WithMockUser(authorities = "ADMIN") -class MusicControllerTest { - - @Autowired - private MockMvc mvc; - - @Autowired - private MusicService musicService; - @Autowired - private MusicRepository musicRepository; - - @BeforeEach - void setUp() throws Exception { - // Given - MockMultipartFile albumCover = getProfileImage(); - - mvc.perform( - multipart("/api/music") - .file("albumCover", albumCover.getBytes()) - .param("title", "<테스트 노래명>") - .param("genre", "INDIE") - .param("karaokeNum", "TJ 53651") - .param("releaseDate", "2019-03-13T00:00:00") - .param("playLink", "https://youtu.be/1gmleC0dOYY?si=ZFejSnIAzEEZx7Xd") - .param("themes", "부드러운 목소리, 테스트 테마!") - ).andExpect(status().isCreated()); - - mvc.perform( - multipart("/api/music") - .file("albumCover", albumCover.getBytes()) - .param("title", "<테스트 노래명2>") - .param("genre", "INDIE") - .param("karaokeNum", "TJ 53651") - .param("releaseDate", "2019-03-13T00:00:00") - .param("playLink", "https://youtu.be/1gmleC0dOYY?si=ZFejSnIAzEEZx7Xd") - ).andExpect(status().isCreated()); - } - - @AfterEach - void cleanUp() throws Exception { - Optional uploaded1 = musicRepository.findByTitle("<테스트 노래명>"); - if (uploaded1.isPresent()) - musicService.delete(uploaded1.get().getMusicId()); - Optional uploaded2 = musicRepository.findByTitle("<테스트 노래명2>"); - if (uploaded2.isPresent()) - musicService.delete(uploaded2.get().getMusicId()); - } +class MusicControllerTest extends ControllerTestSupport { + @DisplayName("새로운 음악을 생성한다.") + @WithMockCustomUser(role = Role.ADMIN) @Test - void create() throws Exception { - // When & Then - Music uploaded = musicRepository.findByTitle("<테스트 노래명>").orElseThrow(); + void createMusic() throws Exception { + // given + MockHttpServletRequestBuilder request = createMusicCreateRequest("BALLADE", "123456"); + + // when then + mockMvc.perform(request) +// .andDo(print()) + .andExpect(status().isCreated()); } + @DisplayName("회원 권한으로 음악을 생성하면 403 오류가 응답된다.") + @WithMockCustomUser(role = Role.USER) @Test - void update() throws Exception { - // Given - Long musicId = musicRepository.findByTitle("<테스트 노래명>").orElseThrow().getMusicId(); - - // When - mvc.perform(put(new URI("/api/music/"+musicId)) - .param("title", "<테스트 노래명>") - .param("genre", "ROCK")) - .andExpect(status().isAccepted()); + void createMusicRoleUser() throws Exception { + // given + MockHttpServletRequestBuilder request = createMusicCreateRequest("BALLADE", "123456"); - // Then - Music music = musicRepository.findByTitle("<테스트 노래명>").orElseThrow(); - assertThat(music.getGenre() == Genre.ROCK); + // when then + mockMvc.perform(request) + .andExpect(status().isForbidden()); } + @DisplayName("음악 생성시 장르 문자열 검증 오류시 400 오류가 응답된다.") + @WithMockCustomUser(role = Role.ADMIN) @Test - void delete() throws Exception { - // When - Music uploaded = musicRepository.findByTitle("<테스트 노래명>").orElseThrow(); - - mvc.perform(MockMvcRequestBuilders.delete(new URI("/api/music/" + uploaded.getMusicId()))) - .andExpect(status().isAccepted()); - - // Then - assertThat(musicRepository.findByTitle("<테스트 노래명>").isEmpty()); - } - - @Test - void list() throws Exception { - // When & Then - mvc.perform(get(new URI("/api/music?page=0&size=2"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content", Matchers.hasSize(2))) - .andExpect(jsonPath("$.content[0].title").value(Matchers.is("<테스트 노래명2>"))) - .andExpect(jsonPath("$.content[1].title").value(Matchers.is("<테스트 노래명>"))); + void createMusicValidateGenre() throws Exception { + // given + MockHttpServletRequestBuilder request = createMusicCreateRequest("발라드", "123456"); + + // when then + mockMvc.perform(request) +// .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.fieldErrors[0].field").value("genre")); } + @DisplayName("음악 생성시 노래방 번호 길이가 50을 넘으면 400 오류가 응답된다.") + @WithMockCustomUser(role = Role.ADMIN) @Test - void listThemes() throws Exception { - // When & Then - mvc.perform(get(new URI("/api/music/theme"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()", Matchers.greaterThanOrEqualTo(2))); + void createMusicValidateKaraokeNum() throws Exception { + // given + StringBuilder numBuilder = new StringBuilder(); + for (int i = 0; i < 51; i++) { + numBuilder.append("1"); + } + + MockHttpServletRequestBuilder request = createMusicCreateRequest("BALLADE", numBuilder.toString()); + + // when then + mockMvc.perform(request) +// .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.fieldErrors[0].field").value("karaokeNum")); } - @Test - void listMusicOfTheme() throws Exception { - // When & Then - mvc.perform(get(new URI("/api/music/theme/music")).param("themeName", "테스트 테마!")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[0].themes").isArray()) - .andExpect(jsonPath("$.content[0].themes").value(Matchers.hasItem("테스트 테마!"))); - } - - - private MockMultipartFile getProfileImage() throws IOException { - final String filename = "albumcover.jpg"; - final String filePath = "src/test/resources/" + filename; - FileInputStream fileInputStream = new FileInputStream(filePath); - MockMultipartFile profileImage = new MockMultipartFile( - "images", - filename, - "jpg", - fileInputStream - ); - return profileImage; + private static MockHttpServletRequestBuilder createMusicCreateRequest(String genre, String karaokeNum) { + return multipart("/api/music") + .param("title", "제목") + .param("genre", genre) + .param("karaokeNum", karaokeNum) + .param("releaseDate", "2010-01-01T00:00:00") + .param("playLink", "link.com") + .param("groupId", "1") + .param("themes", "테마1", "테마2") // 여러 값 전달 + .contentType(MediaType.MULTIPART_FORM_DATA); } } \ No newline at end of file diff --git a/src/test/java/kr/ac/chungbuk/harmonize/controller/annotation/CustomSecurityContextFactory.java b/src/test/java/kr/ac/chungbuk/harmonize/controller/annotation/CustomSecurityContextFactory.java new file mode 100644 index 0000000..e962a1d --- /dev/null +++ b/src/test/java/kr/ac/chungbuk/harmonize/controller/annotation/CustomSecurityContextFactory.java @@ -0,0 +1,27 @@ +package kr.ac.chungbuk.harmonize.controller.annotation; + +import kr.ac.chungbuk.harmonize.entity.User; +import org.springframework.security.test.context.support.WithSecurityContextFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +public class CustomSecurityContextFactory implements WithSecurityContextFactory { + @Override + public SecurityContext createSecurityContext(WithMockCustomUser customUser) { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + + // User 객체 생성 + User user = User.builder() + .loginId(customUser.username()) + .role(customUser.role()) + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken( + user, null, user.getAuthorities() + ); + context.setAuthentication(auth); + return context; + } +} diff --git a/src/test/java/kr/ac/chungbuk/harmonize/controller/annotation/WithMockCustomUser.java b/src/test/java/kr/ac/chungbuk/harmonize/controller/annotation/WithMockCustomUser.java new file mode 100644 index 0000000..b5025c1 --- /dev/null +++ b/src/test/java/kr/ac/chungbuk/harmonize/controller/annotation/WithMockCustomUser.java @@ -0,0 +1,15 @@ +package kr.ac.chungbuk.harmonize.controller.annotation; + + +import kr.ac.chungbuk.harmonize.enums.Role; +import org.springframework.security.test.context.support.WithSecurityContext; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = CustomSecurityContextFactory.class) +public @interface WithMockCustomUser { + String username() default "admin"; + Role role() default Role.ADMIN; +} diff --git a/src/test/java/kr/ac/chungbuk/harmonize/service/ArtistServiceTest.java b/src/test/java/kr/ac/chungbuk/harmonize/service/ArtistServiceTest.java index 5be1267..931bf95 100644 --- a/src/test/java/kr/ac/chungbuk/harmonize/service/ArtistServiceTest.java +++ b/src/test/java/kr/ac/chungbuk/harmonize/service/ArtistServiceTest.java @@ -1,7 +1,6 @@ package kr.ac.chungbuk.harmonize.service; -import kr.ac.chungbuk.harmonize.config.KafkaTopicConfig; -import kr.ac.chungbuk.harmonize.config.ScheduledTask; +import kr.ac.chungbuk.harmonize.IntegrationTestSupport; import kr.ac.chungbuk.harmonize.dto.request.ArtistRequestDto; import kr.ac.chungbuk.harmonize.entity.Artist; import kr.ac.chungbuk.harmonize.enums.Gender; @@ -9,16 +8,12 @@ import kr.ac.chungbuk.harmonize.utility.FileHandler; import kr.ac.chungbuk.harmonize.utility.FileUtils; 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.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; -import org.springframework.kafka.requestreply.ReplyingKafkaTemplate; import org.springframework.mock.web.MockMultipartFile; import java.io.File; @@ -30,8 +25,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@SpringBootTest -class ArtistServiceTest { + +class ArtistServiceTest extends IntegrationTestSupport { @Autowired ArtistService artistService; @@ -40,13 +35,6 @@ class ArtistServiceTest { @Autowired FileHandler fileHandler; - @MockBean - KafkaTopicConfig kafkaTopicConfig; - @MockBean - ReplyingKafkaTemplate replyingKafkaTemplate; - @MockBean - ScheduledTask scheduledTask; - @Value("${file.dir}") String fileDir; diff --git a/src/test/java/kr/ac/chungbuk/harmonize/service/GroupServiceTest.java b/src/test/java/kr/ac/chungbuk/harmonize/service/GroupServiceTest.java index 111b674..b7dbaca 100644 --- a/src/test/java/kr/ac/chungbuk/harmonize/service/GroupServiceTest.java +++ b/src/test/java/kr/ac/chungbuk/harmonize/service/GroupServiceTest.java @@ -1,7 +1,6 @@ package kr.ac.chungbuk.harmonize.service; -import kr.ac.chungbuk.harmonize.config.KafkaTopicConfig; -import kr.ac.chungbuk.harmonize.config.ScheduledTask; +import kr.ac.chungbuk.harmonize.IntegrationTestSupport; import kr.ac.chungbuk.harmonize.dto.request.ArtistRequestDto; import kr.ac.chungbuk.harmonize.dto.request.GroupRequestDto; import kr.ac.chungbuk.harmonize.entity.Artist; @@ -17,11 +16,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; -import org.springframework.kafka.requestreply.ReplyingKafkaTemplate; import org.springframework.mock.web.MockMultipartFile; import java.io.File; @@ -33,10 +29,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest -class GroupServiceTest { + +class GroupServiceTest extends IntegrationTestSupport { @Autowired GroupService groupService; @@ -49,13 +44,6 @@ class GroupServiceTest { @Autowired FileHandler fileHandler; - @MockBean - KafkaTopicConfig kafkaTopicConfig; - @MockBean - ReplyingKafkaTemplate replyingKafkaTemplate; - @MockBean - ScheduledTask scheduledTask; - @Value("${file.dir}") String fileDir; diff --git a/src/test/java/kr/ac/chungbuk/harmonize/service/MusicActionServiceTest.java b/src/test/java/kr/ac/chungbuk/harmonize/service/MusicActionServiceTest.java new file mode 100644 index 0000000..74467a2 --- /dev/null +++ b/src/test/java/kr/ac/chungbuk/harmonize/service/MusicActionServiceTest.java @@ -0,0 +1,232 @@ +package kr.ac.chungbuk.harmonize.service; + +import kr.ac.chungbuk.harmonize.IntegrationTestSupport; +import kr.ac.chungbuk.harmonize.entity.Music; +import kr.ac.chungbuk.harmonize.entity.User; +import kr.ac.chungbuk.harmonize.enums.Genre; +import kr.ac.chungbuk.harmonize.enums.Role; +import kr.ac.chungbuk.harmonize.repository.BookmarkRepository; +import kr.ac.chungbuk.harmonize.repository.MusicRepository; +import kr.ac.chungbuk.harmonize.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.NoSuchElementException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +class MusicActionServiceTest extends IntegrationTestSupport { + + @Autowired + MusicActionService musicActionService; + @Autowired + BookmarkRepository bookmarkRepository; + @Autowired + MusicRepository musicRepository; + @Autowired + UserRepository userRepository; + + @AfterEach + void tearDown() { + bookmarkRepository.deleteAllInBatch(); + musicRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } + + @DisplayName("음악을 북마크(좋아요) 처리합니다.") + @Test + void createBookmark() { + // given + Music music = musicRepository.save(createMusic("음악")); + User user = userRepository.save(createUser()); + + // when + musicActionService.createBookmark(user.getUserId(), music.getMusicId()); + + // then + Boolean result = bookmarkRepository.existsByUserAndMusic(user, music); + assertThat(result).isTrue(); + } + + @DisplayName("음악 북마크 처리시 존재하지 않는 음악 선택시 예외가 발생합니다.") + @Test + void createBookmarkMusicNotExists() { + // given + User user = userRepository.save(createUser()); + + // when then + assertThatThrownBy(() -> musicActionService.createBookmark(user.getUserId(), 999999L)) + .isInstanceOf(NoSuchElementException.class); + } + + @DisplayName("음악 북마크 처리시 존재하지 않는 회원 요청시 예외가 발생합니다.") + @Test + void createBookmarkUserNotExists() { + // given + Music music = musicRepository.save(createMusic("음악")); + + // when then + assertThatThrownBy(() -> musicActionService.createBookmark(999999L, music.getMusicId())) + .isInstanceOf(NoSuchElementException.class); + } + + @DisplayName("이미 북마크(좋아요) 상태의 음악에 북마크 처리시 예외가 발생합니다.") + @Test + void createBookmarkDuplicate() { + // given + Music music = musicRepository.save(createMusic("음악")); + User user = userRepository.save(createUser()); + musicActionService.createBookmark(user.getUserId(), music.getMusicId()); + + // when then + assertThatThrownBy(() -> musicActionService.createBookmark(user.getUserId(), music.getMusicId())) + .isInstanceOf(IllegalStateException.class) + .hasMessage("duplicated bookmark"); + } + + @DisplayName("음악 북마크(좋아요)를 취소합니다.") + @Test + void deleteBookmark() { + // given + Music music = musicRepository.save(createMusic("음악")); + User user = userRepository.save(createUser()); + musicActionService.createBookmark(user.getUserId(), music.getMusicId()); + + // when + musicActionService.deleteBookmark(user.getUserId(), music.getMusicId()); + + // then + Boolean result = bookmarkRepository.existsByUserAndMusic(user, music); + assertThat(result).isFalse(); + } + + @DisplayName("음악 북마크 취소시 존재하지 않는 음악 선택시 예외가 발생합니다.") + @Test + void deleteBookmarkMusicNotExists() { + // given + User user = userRepository.save(createUser()); + + // when then + assertThatThrownBy(() -> musicActionService.deleteBookmark(user.getUserId(), 999999L)) + .isInstanceOf(NoSuchElementException.class); + } + + @DisplayName("음악 북마크 취소시 존재하지 않는 회원 요청시 예외가 발생합니다.") + @Test + void deleteBookmarkUserNotExists() { + // given + Music music = musicRepository.save(createMusic("음악")); + + // when then + assertThatThrownBy(() -> musicActionService.deleteBookmark(999999L, music.getMusicId())) + .isInstanceOf(NoSuchElementException.class); + } + + @DisplayName("음악의 북마크 상태를 조회합니다.") + @Test + void getIsBookmarked() { + // given + Music music = musicRepository.save(createMusic("음악")); + User user = userRepository.save(createUser()); + musicActionService.createBookmark(user.getUserId(), music.getMusicId()); + + // when + boolean isBookmarked = musicActionService.getIsBookmarked(user, music.getMusicId()); + + // then + assertThat(isBookmarked).isTrue(); + } + + @DisplayName("회원이 북마크한 음악 목록을 조회합니다.") + @Test + void listBookmarkedMusic() { + // given + Music music1 = musicRepository.save(createMusic("음악1")); + Music music2 = musicRepository.save(createMusic("음악2")); + User user = userRepository.save(createUser()); + musicActionService.createBookmark(user.getUserId(), music1.getMusicId()); + musicActionService.createBookmark(user.getUserId(), music2.getMusicId()); + + PageRequest pageRequest = PageRequest.of(0, 4); + + // when + Page result = musicActionService.listBookmarkedMusic(user, pageRequest); + + // then + assertThat(result.getTotalElements()).isEqualTo(2); + assertThat(result.getContent()).hasSize(2) + .extracting("title") + .containsExactlyInAnyOrder("음악1", "음악2"); + } + + @DisplayName("로그인되지 않은 회원이 북마크 음악 목록 요청시 예외가 발생합니다.") + @Test + void listBookmarkedMusicUserNotExists() { + // given + PageRequest pageRequest = PageRequest.of(0, 4); + + // when then + assertThatThrownBy(() -> musicActionService.listBookmarkedMusic(null, pageRequest)) + .isInstanceOf(NoSuchElementException.class); + } + + @DisplayName("회원이 북마크한 음악 목록 개수를 조회합니다.") + @Test + void countBookmarkedMusic() { + // given + Music music1 = musicRepository.save(createMusic("음악1")); + Music music2 = musicRepository.save(createMusic("음악2")); + User user = userRepository.save(createUser()); + musicActionService.createBookmark(user.getUserId(), music1.getMusicId()); + musicActionService.createBookmark(user.getUserId(), music2.getMusicId()); + + // when + Long result = musicActionService.countBookmarkedMusic(user); + + // then + assertThat(result).isEqualTo(2); + } + + @DisplayName("로그인되지 않은 회원이 북마크 음악 수 조회시 예외가 발생합니다.") + @Test + void countBookmarkedMusicUserNotExists() { + // when then + assertThatThrownBy(() -> musicActionService.countBookmarkedMusic(null)) + .isInstanceOf(NoSuchElementException.class); + } + + private Music createMusic(String title) { + return Music.builder() + .title(title) + .genre(Genre.BALLADE) + .karaokeNum("12345") + .releaseDate(LocalDateTime.of( + LocalDate.of(2000, 12, 21), + LocalTime.of(0, 0, 0) + )) + .playLink("link.com") + .view(0L) + .likes(0L) + .build(); + } + + private User createUser() { + return User.builder() + .loginId("loginId") + .password("password") + .email("email@email.com") + .nickname("홍길동") + .role(Role.USER) + .build(); + } + +} \ No newline at end of file diff --git a/src/test/java/kr/ac/chungbuk/harmonize/service/MusicAnalysisServiceTest.java b/src/test/java/kr/ac/chungbuk/harmonize/service/MusicAnalysisServiceTest.java new file mode 100644 index 0000000..1cf3197 --- /dev/null +++ b/src/test/java/kr/ac/chungbuk/harmonize/service/MusicAnalysisServiceTest.java @@ -0,0 +1,506 @@ +package kr.ac.chungbuk.harmonize.service; + +import kr.ac.chungbuk.harmonize.IntegrationTestSupport; +import kr.ac.chungbuk.harmonize.dto.request.MusicRequestDto; +import kr.ac.chungbuk.harmonize.entity.Music; +import kr.ac.chungbuk.harmonize.entity.MusicAnalysis; +import kr.ac.chungbuk.harmonize.entity.User; +import kr.ac.chungbuk.harmonize.enums.Role; +import kr.ac.chungbuk.harmonize.enums.Status; +import kr.ac.chungbuk.harmonize.repository.MusicAnalysisRepository; +import kr.ac.chungbuk.harmonize.repository.MusicRepository; +import kr.ac.chungbuk.harmonize.repository.UserRepository; +import kr.ac.chungbuk.harmonize.utility.FileHandler; +import kr.ac.chungbuk.harmonize.utility.FileUtils; +import org.apache.tomcat.util.http.fileupload.impl.SizeLimitExceededException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.kafka.requestreply.RequestReplyMessageFuture; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.*; + +@SuppressWarnings("unchecked") +class MusicAnalysisServiceTest extends IntegrationTestSupport { + + @Autowired + MusicAnalysisService musicAnalysisService; + @Autowired + MusicAnalysisRepository musicAnalysisRepository; + @Autowired + MusicService musicService; + @Autowired + MusicRepository musicRepository; + @Autowired + UserRepository userRepository; + @Autowired + FileHandler fileHandler; + + @Value("${file.dir}") + String fileDir; + + @AfterEach + void tearDown() { + musicAnalysisRepository.deleteAllInBatch(); + musicRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + + FileUtils.deleteFolderContents(new File(fileDir)); + } + + @DisplayName("음악 오디오 파일을 업로드합니다.") + @Test + void updateFilesAudio() throws IOException { + // given + MusicRequestDto musicRequest = createMusicRequest("음악"); + Music music = musicService.create(musicRequest); + + MultipartFile audioFile = getAudioFile(); + + // when + musicAnalysisService.updateFiles(music.getMusicId(), audioFile, null); + + // then + Music result = musicRepository.findById(music.getMusicId()).orElseThrow(); + File audioFileSaved = new File(fileHandler.getAudioDirectoryPath() + music.getMusicId() + ".mp3"); + + assertThat(result.getAudioFile()).isEqualTo("/api/music/audio/"+music.getMusicId()+".mp3"); + assertThat(audioFileSaved).exists(); + } + + @DisplayName("가사 텍스트 파일을 업로드합니다.") + @Test + void updateFilesLyric() throws IOException { + // given + MusicRequestDto musicRequest = createMusicRequest("음악"); + Music music = musicService.create(musicRequest); + + MultipartFile lyricFile = getLyricFile("lyrics.txt"); + + // when + musicAnalysisService.updateFiles(music.getMusicId(), null, lyricFile); + + // then + Music result = musicRepository.findById(music.getMusicId()).orElseThrow(); + assertThat(result.getLyrics()).isEqualTo("가사 업로드"); + } + + @DisplayName("가사 파일의 용량이 10kb를 초과하면 예외가 발생합니다.") + @Test + void updateFilesLyricSizeExceeded() throws IOException { + // given + MusicRequestDto musicRequest = createMusicRequest("음악"); + Music music = musicService.create(musicRequest); + + MultipartFile lyricFile = getLyricFile("lyricsSizeExceeded.txt"); + + // when then + assertThatThrownBy(() -> musicAnalysisService.updateFiles(music.getMusicId(), null, lyricFile)) + .isInstanceOf(SizeLimitExceededException.class) + .hasMessage("Too heavy lyricFile"); + } + + @DisplayName("(음악 벌크) 파일 이름과 일치하는 음악에 음악 파일을 업로드합니다.") + @Test + void updateAudioFile() throws IOException { + // given + MusicRequestDto musicRequest = createMusicRequest("audio"); + Music music = musicService.create(musicRequest); + + MultipartFile audioFile = getAudioFile(); + + // when + musicAnalysisService.updateAudioFile(audioFile); + + // then + Music result = musicRepository.findById(music.getMusicId()).orElseThrow(); + File audioFileSaved = new File(fileHandler.getAudioDirectoryPath() + music.getMusicId() + ".mp3"); + + assertThat(result.getAudioFile()).isEqualTo("/api/music/audio/"+music.getMusicId()+".mp3"); + assertThat(audioFileSaved).exists(); + } + + @DisplayName("(음악 벌크) 파일 이름과 일치하는 음악이 두 개 이상이면 예외가 발생합니다.") + @Test + void updateAudioFileDuplicateTitle() throws IOException { + // given + MusicRequestDto musicRequest1 = createMusicRequest("audio"); + Music music1 = musicService.create(musicRequest1); + MusicRequestDto musicRequest2 = createMusicRequest("audio"); + Music music2 = musicService.create(musicRequest2); + + MultipartFile audioFile = getAudioFile(); + + // when then + assertThatThrownBy(() -> musicAnalysisService.updateAudioFile(audioFile)) + .isInstanceOf(IncorrectResultSizeDataAccessException.class); + } + + @DisplayName("(음악 벌크) 파일 이름과 일치하는 음악이 존재하지 않으면 예외가 발생합니다.") + @Test + void updateAudioFileNotExists() throws IOException { + // given + MultipartFile audioFile = getAudioFile(); + + // when then + assertThatThrownBy(() -> musicAnalysisService.updateAudioFile(audioFile)) + .isInstanceOf(NoSuchElementException.class); + } + + @DisplayName("(가사 벌크) 파일 이름과 일치하는 음악에 가사를 업로드합니다.") + @Test + void updateLyricFile() throws Exception { + // given + MusicRequestDto musicRequest = createMusicRequest("lyrics"); + Music music = musicService.create(musicRequest); + + MultipartFile lyricFile = getLyricFile("lyrics.txt"); + + // when + musicAnalysisService.updateLyricFile(lyricFile); + + // then + Music result = musicRepository.findById(music.getMusicId()).orElseThrow(); + assertThat(result.getLyrics()).isEqualTo("가사 업로드"); + } + + @DisplayName("(가사 벌크) 파일 이름과 일치하는 음악이 두 개 이상이면 예외가 발생합니다.") + @Test + void updateLyricFileDuplicateTitle() throws IOException { + // given + MusicRequestDto musicRequest1 = createMusicRequest("lyrics"); + Music music1 = musicService.create(musicRequest1); + MusicRequestDto musicRequest2 = createMusicRequest("lyrics"); + Music music2 = musicService.create(musicRequest2); + + MultipartFile lyricFile = getLyricFile("lyrics.txt"); + + // when then + assertThatThrownBy(() -> musicAnalysisService.updateLyricFile(lyricFile)) + .isInstanceOf(IncorrectResultSizeDataAccessException.class); + } + + @DisplayName("(가사 벌크) 파일 이름과 일치하는 음악이 존재하지 않으면 예외가 발생합니다.") + @Test + void updateLyricFileNotExists() throws IOException { + // given + MultipartFile lyricFile = getLyricFile("lyrics.txt"); + + // when then + assertThatThrownBy(() -> musicAnalysisService.updateLyricFile(lyricFile)) + .isInstanceOf(NoSuchElementException.class); + } + + @DisplayName("음악 분석 모델로 분석 요청을 전송합니다.") + @Test + void analyze() throws IOException { + // given + MusicRequestDto musicRequest = createMusicRequest("audio"); + Music music = musicService.create(musicRequest); + MultipartFile audioFile = getAudioFile(); + musicAnalysisService.updateAudioFile(audioFile); + + given(kafkaTemplate.send(anyString(), anyString(), anyString())) + .willReturn(null); + + double confidence = 0.8; + + // when + musicAnalysisService.analyze(music.getMusicId(), confidence); + + // then + verify(kafkaTemplate).send( + eq("musicAnalysis"), + contains("\"command\": \"analysis\"") + ); + } + + @DisplayName("음악 분석 요청시 음악 파일이 업로드되지 않았으면 예외가 발생합니다.") + @Test + void analyzeNoAudioFile() throws IOException { + // given + MusicRequestDto musicRequest = createMusicRequest("음악"); + Music music = musicService.create(musicRequest); + + double confidence = 0.8; + + // when then + assertThatThrownBy(() -> musicAnalysisService.analyze(music.getMusicId(), confidence)) + .isInstanceOf(FileNotFoundException.class) + .hasMessage("Audio file not uploaded"); + } + + @DisplayName("음악 분석 모델로 모델을 돌리지 않고 기존 값만 재분석 요청합니다.") + @Test + void analyzeWithoutModel() throws IOException { + // given + MusicRequestDto musicRequest = createMusicRequest("audio"); + Music music = musicService.create(musicRequest); + MultipartFile audioFile = getAudioFile(); + musicAnalysisService.updateAudioFile(audioFile); + + copySampleTestResultXlsxFile(music); + + given(kafkaTemplate.send(anyString(), anyString(), anyString())) + .willReturn(null); + + // when + musicAnalysisService.analyzeWithoutModel(music.getMusicId()); + + // then + verify(kafkaTemplate).send( + eq("musicAnalysis"), + contains("\"command\": \"analysis_offline\"") + ); + } + + @DisplayName("음악 재분석 요청시 모델 결과 파일이 없으면 예외가 발생합니다.") + @Test + void analyzeWithoutModelNoXlsxFile() throws IOException { + // given + MusicRequestDto musicRequest = createMusicRequest("audio"); + Music music = musicService.create(musicRequest); + MultipartFile audioFile = getAudioFile(); + musicAnalysisService.updateAudioFile(audioFile); + + // when then + assertThatThrownBy(() -> musicAnalysisService.analyzeWithoutModel(music.getMusicId())) + .isInstanceOf(FileNotFoundException.class) + .hasMessage(music.getMusicId() + "번 음악 xlsx 파일이 존재하지 않음"); + } + + @DisplayName("음악 분석 결과에서 특정 Pitch 값을 제거합니다.") + @Test + void deletePitch() throws Exception { + // given + MusicRequestDto musicRequest = createMusicRequest("audio"); + Music music = musicService.create(musicRequest); + MultipartFile audioFile = getAudioFile(); + musicAnalysisService.updateAudioFile(audioFile); + + copySampleTestResultXlsxFile(music); + MusicAnalysis analysis = musicAnalysisRepository.save(new MusicAnalysis(music.getMusicId(), Status.COMPLETE)); + music.setAnalysis(analysis); + musicRepository.save(music); + + given(kafkaTemplate.send(anyString(), anyString(), anyString())) + .willReturn(null); + + // when + musicAnalysisService.deletePitch(music.getMusicId(), 0.123); + + // then + verify(kafkaTemplate).send( + eq("musicAnalysis"), + contains("\"command\": \"delete\"") + ); + } + + @DisplayName("음악 분석 결과에서 특정 범위 Pitch 값 전체를 제거합니다.") + @Test + void deletePitchRange() throws Exception { + // given + MusicRequestDto musicRequest = createMusicRequest("audio"); + Music music = musicService.create(musicRequest); + MultipartFile audioFile = getAudioFile(); + musicAnalysisService.updateAudioFile(audioFile); + + copySampleTestResultXlsxFile(music); + MusicAnalysis analysis = musicAnalysisRepository.save(new MusicAnalysis(music.getMusicId(), Status.COMPLETE)); + music.setAnalysis(analysis); + musicRepository.save(music); + + given(kafkaTemplate.send(anyString(), anyString(), anyString())) + .willReturn(null); + + // when + musicAnalysisService.deletePitchRange(music.getMusicId(), 0.123, "upper"); + + // then + verify(kafkaTemplate).send( + eq("musicAnalysis"), + contains("\"command\": \"delete\"") + ); + } + + @DisplayName("음악 분석 결과 수정 요청시 분석이 완료되지 않았으면 예외가 발생합니다.") + @Test + void deletePitchNotComplete() throws Exception { + // given + MusicRequestDto musicRequest = createMusicRequest("audio"); + Music music = musicService.create(musicRequest); + MultipartFile audioFile = getAudioFile(); + musicAnalysisService.updateAudioFile(audioFile); + + copySampleTestResultXlsxFile(music); + + given(kafkaTemplate.send(anyString(), anyString(), anyString())) + .willReturn(null); + + // when then + assertThatThrownBy(() -> musicAnalysisService.deletePitch(music.getMusicId(), 0.123)) + .isInstanceOf(Exception.class) + .hasMessage("Analysis status is not COMPLETE"); + assertThatThrownBy(() -> musicAnalysisService.deletePitchRange(music.getMusicId(), 0.123, "upper")) + .isInstanceOf(Exception.class) + .hasMessage("Analysis status is not COMPLETE"); + } + + @DisplayName("콘텐츠 기반 추천 결과 업데이트 요청을 보냅니다.") + @Test + void requestContentBasedRec() { + // given + given(kafkaTemplate.send(anyString(), anyString(), anyString())) + .willReturn(null); + + // when + musicAnalysisService.requestContentBasedRec(); + + // then + verify(kafkaTemplate).send( + eq("musicRecSys"), + contains("\"command\": \"content-based\"") + ); + } + + @DisplayName("전체 회원 대상 추천 결과 업데이트 요청을 보냅니다.") + @Test + void requestCollaborativeRec() throws ExecutionException, InterruptedException, TimeoutException { + // given + String expectedPayload = "all"; + GenericMessage mockMessage = new GenericMessage<>(expectedPayload); + RequestReplyMessageFuture mockFuture = mock(RequestReplyMessageFuture.class); + + given(mockFuture.get(20, TimeUnit.SECONDS)).willReturn(mockMessage); + given(kafkaTemplate.sendAndReceive(any(Message.class))).willReturn(mockFuture); + + // when + Object payload = musicAnalysisService.requestCollaborativeRec(); + + // then + assertThat(payload).isEqualTo(expectedPayload); + } + + @DisplayName("한 명의 회원 대상 추천 결과 업데이트 요청을 보냅니다.") + @Test + void requestCollaborativeRecOne() throws ExecutionException, InterruptedException, TimeoutException { + // given + User user = userRepository.save(createUser()); + + String expectedPayload = String.valueOf(user.getUserId()); + GenericMessage mockMessage = new GenericMessage<>(expectedPayload); + RequestReplyMessageFuture mockFuture = mock(RequestReplyMessageFuture.class); + + given(mockFuture.get(20, TimeUnit.SECONDS)).willReturn(mockMessage); + given(kafkaTemplate.sendAndReceive(any(Message.class))).willReturn(mockFuture); + + // when + Object payload = musicAnalysisService.requestCollaborativeRecOne(user.getUserId()); + + // then + assertThat(payload).isEqualTo(expectedPayload); + } + + @DisplayName("모델의 현재 상태를 확인합니다.") + @Test + void checkSystemStatus() throws ExecutionException, InterruptedException, TimeoutException { + // given + String expectedPayload = "pong"; + GenericMessage mockMessage = new GenericMessage<>(expectedPayload); + RequestReplyMessageFuture mockFuture = mock(RequestReplyMessageFuture.class); + + given(mockFuture.get(2, TimeUnit.SECONDS)).willReturn(mockMessage); + given(kafkaTemplate.sendAndReceive(any(Message.class))).willReturn(mockFuture); + + // when + Map status = musicAnalysisService.checkSystemStatus(); + + // then + assertThat(status.get("musicAnalysis")).isTrue(); + assertThat(status.get("recSys")).isTrue(); + } + + + private MusicRequestDto createMusicRequest(String title) { + return MusicRequestDto.builder() + .title(title) + .genre("KPOP") + .karaokeNum("12345") + .releaseDate(LocalDateTime.of( + LocalDate.of(2000, 12, 21), + LocalTime.of(0, 0, 0) + )) + .playLink("link.com") + .build(); + } + + private User createUser() { + return User.builder() + .loginId("loginId") + .password("password") + .email("email@email.com") + .nickname("홍길동") + .role(Role.USER) + .build(); + } + + private MockMultipartFile getAudioFile() throws IOException { + final String filename = "audio.mp3"; + final String filePath = "src/test/resources/" + filename; + FileInputStream fileInputStream = new FileInputStream(filePath); + + return new MockMultipartFile( + "audio", + filename, + "audio/mpeg", + fileInputStream + ); + } + + private MockMultipartFile getLyricFile(String filename) throws IOException { + final String filePath = "src/test/resources/" + filename; + FileInputStream fileInputStream = new FileInputStream(filePath); + + return new MockMultipartFile( + "file", + filename, + "text/plain", + fileInputStream + ); + } + + private void copySampleTestResultXlsxFile(Music music) throws IOException { + Path folderPath = Paths.get(fileHandler.getAudioDirectoryPath() + music.getMusicId()); + Files.createDirectory(folderPath); + + Path source = Paths.get("src/test/resources/pitch.xlsx"); + Path target = Paths.get(fileHandler.getAudioDirectoryPath() + music.getMusicId() + "/pitch.xlsx"); + Files.copy(source, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } +} \ No newline at end of file diff --git a/src/test/java/kr/ac/chungbuk/harmonize/service/MusicServiceTest.java b/src/test/java/kr/ac/chungbuk/harmonize/service/MusicServiceTest.java index 407ab96..9d9f94d 100644 --- a/src/test/java/kr/ac/chungbuk/harmonize/service/MusicServiceTest.java +++ b/src/test/java/kr/ac/chungbuk/harmonize/service/MusicServiceTest.java @@ -1,7 +1,6 @@ package kr.ac.chungbuk.harmonize.service; -import kr.ac.chungbuk.harmonize.config.KafkaTopicConfig; -import kr.ac.chungbuk.harmonize.config.ScheduledTask; +import kr.ac.chungbuk.harmonize.IntegrationTestSupport; import kr.ac.chungbuk.harmonize.dto.request.GroupRequestDto; import kr.ac.chungbuk.harmonize.dto.request.MusicRequestDto; import kr.ac.chungbuk.harmonize.dto.request.SearchRequestDto; @@ -20,11 +19,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; -import org.springframework.kafka.requestreply.ReplyingKafkaTemplate; import org.springframework.mock.web.MockMultipartFile; import java.io.File; @@ -41,8 +37,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@SpringBootTest -class MusicServiceTest { + +class MusicServiceTest extends IntegrationTestSupport { @Autowired MusicService musicService; @@ -61,13 +57,6 @@ class MusicServiceTest { @Autowired FileHandler fileHandler; - @MockBean - KafkaTopicConfig kafkaTopicConfig; - @MockBean - ReplyingKafkaTemplate replyingKafkaTemplate; - @MockBean - ScheduledTask scheduledTask; - @Value("${file.dir}") String fileDir; diff --git a/src/test/resources/audio.mp3 b/src/test/resources/audio.mp3 new file mode 100644 index 0000000..2aaa278 Binary files /dev/null and b/src/test/resources/audio.mp3 differ diff --git a/src/test/resources/lyrics.txt b/src/test/resources/lyrics.txt new file mode 100644 index 0000000..8225ae4 --- /dev/null +++ b/src/test/resources/lyrics.txt @@ -0,0 +1 @@ +가사 업로드 \ No newline at end of file diff --git a/src/test/resources/lyricsSizeExceeded.txt b/src/test/resources/lyricsSizeExceeded.txt new file mode 100644 index 0000000..49b07dd --- /dev/null +++ b/src/test/resources/lyricsSizeExceeded.txt @@ -0,0 +1,836 @@ +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 +가사 업로드 \ No newline at end of file diff --git a/src/test/resources/pitch.xlsx b/src/test/resources/pitch.xlsx new file mode 100644 index 0000000..6f70f03 Binary files /dev/null and b/src/test/resources/pitch.xlsx differ