From fa8be3c35608ffc64b4ec545fce6cd72435f54eb Mon Sep 17 00:00:00 2001 From: somin <70891072+somsom13@users.noreply.github.com> Date: Thu, 19 Oct 2023 23:50:31 +0900 Subject: [PATCH] =?UTF-8?q?Feat/#515=20=EC=BA=90=EB=9F=AC=EC=85=80?= =?UTF-8?q?=EC=9A=A9=20=EC=B5=9C=EC=8B=A0=20=EB=85=B8=EB=9E=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#517)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 캐러셀용 최신 노래 조회 API 구현 * fix: voting_song_part 테이블 삭제 스키마 추가 * feat: 캐러셀용 최신 노래 조회 API 구현 * fix: voting_song_part 테이블 삭제 스키마 추가 * fix: 킬링파트 길이 수정 * feat: 캐러셀용 최신 노래 조회 API 구현 * fix: voting_song_part 테이블 삭제 스키마 추가 * feat: 캐러셀용 최신 노래 조회 API 구현 * fix: 킬링파트 길이 수정 * fix: Artist 엔티티 병합, 실패하는 테스트 수정 --------- Co-authored-by: 스플릿 --- .../shook/song/application/SongService.java | 11 ++ .../dto/RecentSongCarouselResponse.java | 37 ++++++ .../domain/repository/SongRepository.java | 3 + .../shook/song/ui/CarouselSongController.java | 29 +++++ .../song/ui/openapi/CarouselSongApi.java | 33 ++++++ backend/src/main/resources/dev/schema.sql | 1 + .../song/application/SongServiceTest.java | 25 ++++ .../domain/repository/SongRepositoryTest.java | 18 +++ .../song/ui/CarouselSongControllerTest.java | 108 ++++++++++++++++++ 9 files changed, 265 insertions(+) create mode 100644 backend/src/main/java/shook/shook/song/application/dto/RecentSongCarouselResponse.java create mode 100644 backend/src/main/java/shook/shook/song/ui/CarouselSongController.java create mode 100644 backend/src/main/java/shook/shook/song/ui/openapi/CarouselSongApi.java create mode 100644 backend/src/test/java/shook/shook/song/ui/CarouselSongControllerTest.java diff --git a/backend/src/main/java/shook/shook/song/application/SongService.java b/backend/src/main/java/shook/shook/song/application/SongService.java index 7484e252..8f417a8a 100644 --- a/backend/src/main/java/shook/shook/song/application/SongService.java +++ b/backend/src/main/java/shook/shook/song/application/SongService.java @@ -5,6 +5,7 @@ import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -13,8 +14,10 @@ import shook.shook.member.domain.Member; import shook.shook.member.domain.repository.MemberRepository; import shook.shook.member.exception.MemberException; +import shook.shook.song.application.dto.RecentSongCarouselResponse; import shook.shook.member_part.domain.MemberPart; import shook.shook.member_part.domain.repository.MemberPartRepository; +import shook.shook.song.application.dto.RecentSongCarouselResponse; import shook.shook.song.application.dto.SongResponse; import shook.shook.song.application.dto.SongSwipeResponse; import shook.shook.song.application.dto.SongWithKillingPartsRegisterRequest; @@ -232,4 +235,12 @@ public SongResponse findSongById(final Long songId, final MemberInfo memberInfo) return SongResponse.of(song, likedKillingPartIds, memberPart); } + + public List findRecentRegisteredSongsForCarousel(final Integer size) { + final List topSongs = songRepository.findSongsOrderById(PageRequest.of(0, size)); + + return topSongs.stream() + .map(RecentSongCarouselResponse::from) + .toList(); + } } diff --git a/backend/src/main/java/shook/shook/song/application/dto/RecentSongCarouselResponse.java b/backend/src/main/java/shook/shook/song/application/dto/RecentSongCarouselResponse.java new file mode 100644 index 00000000..284e43d9 --- /dev/null +++ b/backend/src/main/java/shook/shook/song/application/dto/RecentSongCarouselResponse.java @@ -0,0 +1,37 @@ +package shook.shook.song.application.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import shook.shook.song.domain.Song; + +@Schema(description = "캐러셀에 보여질 최근 노래 응답") +@AllArgsConstructor +@Getter +public class RecentSongCarouselResponse { + + @Schema(description = "노래 id", example = "1") + private final Long id; + + @Schema(description = "노래 제목", example = "노래제목") + private final String title; + + @Schema(description = "가수 이름", example = "가수") + private final String singer; + + @Schema(description = "비디오 영상 길이", example = "274") + private final int videoLength; + + @Schema(description = "앨범 자켓 이미지 url", example = "https://image.com/album_cover.jpg") + private final String albumCoverUrl; + + public static RecentSongCarouselResponse from(final Song song) { + return new RecentSongCarouselResponse( + song.getId(), + song.getTitle(), + song.getArtistName(), + song.getLength(), + song.getAlbumCoverUrl() + ); + } +} diff --git a/backend/src/main/java/shook/shook/song/domain/repository/SongRepository.java b/backend/src/main/java/shook/shook/song/domain/repository/SongRepository.java index a0c09ce1..d9321bd8 100644 --- a/backend/src/main/java/shook/shook/song/domain/repository/SongRepository.java +++ b/backend/src/main/java/shook/shook/song/domain/repository/SongRepository.java @@ -47,6 +47,9 @@ List findSongsWithMoreLikeCountThanSongWithId( final Pageable pageable ); + @Query("SELECT s from Song s ORDER BY s.id DESC") + List findSongsOrderById(final Pageable pageable); + boolean existsSongByTitle(final SongTitle title); @Query("SELECT s AS song, SUM(COALESCE(kp.likeCount, 0)) AS totalLikeCount " diff --git a/backend/src/main/java/shook/shook/song/ui/CarouselSongController.java b/backend/src/main/java/shook/shook/song/ui/CarouselSongController.java new file mode 100644 index 00000000..b169b032 --- /dev/null +++ b/backend/src/main/java/shook/shook/song/ui/CarouselSongController.java @@ -0,0 +1,29 @@ +package shook.shook.song.ui; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import shook.shook.song.application.SongService; +import shook.shook.song.application.dto.RecentSongCarouselResponse; +import shook.shook.song.ui.openapi.CarouselSongApi; + +@RequiredArgsConstructor +@RequestMapping("/songs/recent") +@RestController +public class CarouselSongController implements CarouselSongApi { + + private final SongService songService; + + @GetMapping + public ResponseEntity> findRecentSongsForCarousel( + @RequestParam(name = "size", defaultValue = "5", required = false) final Integer size + ) { + final List responses = songService.findRecentRegisteredSongsForCarousel(size); + + return ResponseEntity.ok(responses); + } +} diff --git a/backend/src/main/java/shook/shook/song/ui/openapi/CarouselSongApi.java b/backend/src/main/java/shook/shook/song/ui/openapi/CarouselSongApi.java new file mode 100644 index 00000000..9701dca6 --- /dev/null +++ b/backend/src/main/java/shook/shook/song/ui/openapi/CarouselSongApi.java @@ -0,0 +1,33 @@ +package shook.shook.song.ui.openapi; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import shook.shook.song.application.dto.RecentSongCarouselResponse; + +@Tag(name = "Carousel Songs", description = "메인페이지 캐러셀 조회 API") +public interface CarouselSongApi { + + @Operation( + summary = "캐러셀에 들어갈 노래 반환", + description = "캐러셀에 들어갈 노래 5개를 등록 최신 순 리스트로 반환한다." + ) + @ApiResponse( + responseCode = "200", + description = "최근에 등록된 노래 리스트 조회 성공" + ) + @Parameter( + name = "size", + description = "조회할 개수", + example = "4" + ) + @GetMapping + ResponseEntity> findRecentSongsForCarousel( + @RequestParam(name = "size", defaultValue = "5", required = false) final Integer size + ); +} diff --git a/backend/src/main/resources/dev/schema.sql b/backend/src/main/resources/dev/schema.sql index 9cc59c43..3d9cdac2 100644 --- a/backend/src/main/resources/dev/schema.sql +++ b/backend/src/main/resources/dev/schema.sql @@ -4,6 +4,7 @@ drop table if exists killing_part_like; drop table if exists killing_part_comment; drop table if exists voting_song_part; drop table if exists voting_song; +drop table if exists voting_song_part; drop table if exists vote; drop table if exists member; drop table if exists artist; diff --git a/backend/src/test/java/shook/shook/song/application/SongServiceTest.java b/backend/src/test/java/shook/shook/song/application/SongServiceTest.java index b4c981b4..4cd3d876 100644 --- a/backend/src/test/java/shook/shook/song/application/SongServiceTest.java +++ b/backend/src/test/java/shook/shook/song/application/SongServiceTest.java @@ -18,6 +18,7 @@ import shook.shook.member_part.domain.MemberPart; import shook.shook.member_part.domain.repository.MemberPartRepository; import shook.shook.song.application.dto.KillingPartRegisterRequest; +import shook.shook.song.application.dto.RecentSongCarouselResponse; import shook.shook.song.application.dto.MemberPartResponse; import shook.shook.song.application.dto.SongResponse; import shook.shook.song.application.dto.SongSwipeResponse; @@ -538,4 +539,28 @@ void findSongById() { () -> assertThat(response.getMemberPart().getId()).isNotNull() ); } + + @DisplayName("최근에 등록된 순으로 노래 5개를 조회한다.") + @Test + void findRecentRegisteredSongsForCarousel() { + // given + registerNewSong("노래1"); + registerNewSong("노래2"); + registerNewSong("노래3"); + registerNewSong("노래4"); + registerNewSong("노래5"); + registerNewSong("노래6"); + registerNewSong("노래7"); + + saveAndClearEntityManager(); + + // when + final List songs = songService.findRecentRegisteredSongsForCarousel(5); + + // then + assertThat(songs.stream() + .map(RecentSongCarouselResponse::getId) + .toList()) + .containsExactly(7L, 6L, 5L, 4L, 3L); + } } diff --git a/backend/src/test/java/shook/shook/song/domain/repository/SongRepositoryTest.java b/backend/src/test/java/shook/shook/song/domain/repository/SongRepositoryTest.java index 1fab977f..56b3aa76 100644 --- a/backend/src/test/java/shook/shook/song/domain/repository/SongRepositoryTest.java +++ b/backend/src/test/java/shook/shook/song/domain/repository/SongRepositoryTest.java @@ -47,6 +47,7 @@ private Song createNewSongWithKillingParts() { final KillingPart thirdKillingPart = KillingPart.forSave(20, 5); final Artist artist = new Artist("image", "name"); + artistRepository.save(artist); return new Song( "title", "3rUPND6FG8A", @@ -438,4 +439,21 @@ void findAllSongsWithTotalLikeCountByArtist() { () -> assertThat(result.get(0).getTotalLikeCount()).isEqualTo(4) ); } + + @DisplayName("노래 최신순으로 정렬하여 상위 노래를 조회한다.") + @Test + void findSongsOrderById() { + // given + final Song song1 = songRepository.save(createNewSongWithKillingParts()); + final Song song2 = songRepository.save(createNewSongWithKillingParts()); + final Song song3 = songRepository.save(createNewSongWithKillingParts()); + final Song song4 = songRepository.save(createNewSongWithKillingParts()); + final Song song5 = songRepository.save(createNewSongWithKillingParts()); + + // when + final List songs = songRepository.findSongsOrderById(PageRequest.of(0, 4)); + + // then + assertThat(songs).containsExactly(song5, song4, song3, song2); + } } diff --git a/backend/src/test/java/shook/shook/song/ui/CarouselSongControllerTest.java b/backend/src/test/java/shook/shook/song/ui/CarouselSongControllerTest.java new file mode 100644 index 00000000..60664f2a --- /dev/null +++ b/backend/src/test/java/shook/shook/song/ui/CarouselSongControllerTest.java @@ -0,0 +1,108 @@ +package shook.shook.song.ui; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.restassured.RestAssured; +import java.util.List; +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.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.jdbc.Sql; +import shook.shook.song.application.dto.RecentSongCarouselResponse; +import shook.shook.song.domain.Artist; +import shook.shook.song.domain.Genre; +import shook.shook.song.domain.KillingParts; +import shook.shook.song.domain.Song; +import shook.shook.song.domain.killingpart.KillingPart; +import shook.shook.song.domain.repository.ArtistRepository; +import shook.shook.song.domain.repository.SongRepository; + +@Sql("classpath:/killingpart/initialize_killing_part_song.sql") +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class CarouselSongControllerTest { + + @LocalServerPort + private int port; + + @Autowired + private SongRepository songRepository; + + @Autowired + private ArtistRepository artistRepository; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @DisplayName("캐러셀에 보여질 노래들을 조회하면 200 상태코드와 id 높은 순 노래 데이터가 반환된다.") + @Test + void findRecentSongsForCarousel() { + // given + songRepository.findById(3L).get(); + songRepository.findById(4L).get(); + songRepository.save(createNewSongWithKillingParts()); + songRepository.save(createNewSongWithKillingParts()); + + // when + final List response = RestAssured.given().log().all() + .param("size", 4) + .when().log().all() + .get("/songs/recent") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .body().jsonPath().getList(".", RecentSongCarouselResponse.class); + + // then + assertThat(response).hasSize(4); + assertThat(response.stream() + .map(RecentSongCarouselResponse::getId) + .toList()) + .containsExactly(6L, 5L, 4L, 3L); + } + + @DisplayName("캐러셀에 보여질 노래들을 조회할 때, size 파라미터가 전달되지 않으면 기본값인 5개가 조회된다.") + @Test + void findRecentSongsForCarousel_noParam() { + // given + songRepository.findById(3L).get(); + songRepository.findById(4L).get(); + songRepository.save(createNewSongWithKillingParts()); + songRepository.save(createNewSongWithKillingParts()); + songRepository.save(createNewSongWithKillingParts()); + + // when + final List response = RestAssured.given().log().all() + .when().log().all() + .get("/songs/recent") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .body().jsonPath().getList(".", RecentSongCarouselResponse.class); + + // then + assertThat(response).hasSize(5); + assertThat(response.stream() + .map(RecentSongCarouselResponse::getId) + .toList()) + .containsExactly(7L, 6L, 5L, 4L, 3L); + } + + private Song createNewSongWithKillingParts() { + final KillingPart firstKillingPart = KillingPart.forSave(10, 5); + final KillingPart secondKillingPart = KillingPart.forSave(15, 5); + final KillingPart thirdKillingPart = KillingPart.forSave(20, 5); + + final Artist artist = new Artist("image", "name"); + artistRepository.save(artist); + return new Song( + "제목", "비디오ID는 11글자", "이미지URL", artist, 5, Genre.from("댄스"), + new KillingParts(List.of(firstKillingPart, secondKillingPart, thirdKillingPart))); + } +}