diff --git a/backend/src/main/java/shook/shook/globalexception/ErrorCode.java b/backend/src/main/java/shook/shook/globalexception/ErrorCode.java index 74be590af..be84048fd 100644 --- a/backend/src/main/java/shook/shook/globalexception/ErrorCode.java +++ b/backend/src/main/java/shook/shook/globalexception/ErrorCode.java @@ -56,7 +56,8 @@ public enum ErrorCode { EMPTY_ARTIST_PROFILE_URL(3014, "가수 프로필 이미지는 비어있을 수 없습니다."), TOO_LONG_ARTIST_PROFILE_URL(3015, "가수 프로필 이미지URL은 65,356자를 넘길 수 없습니다."), EMPTY_ARTIST_SYNONYM(3016, "가수 동의어는 비어있을 수 없습니다."), - TOO_LONG_ARTIST_SYNONYM(3017, "가수 동의어는 255자를 넘길 수 없습니다.."), + TOO_LONG_ARTIST_SYNONYM(3017, "가수 동의어는 255자를 넘길 수 없습니다."), + ARTIST_NOT_EXIST(3018, "존재하지 않는 가수입니다."), // 4000: 투표 diff --git a/backend/src/main/java/shook/shook/song/application/ArtistSearchService.java b/backend/src/main/java/shook/shook/song/application/ArtistSearchService.java new file mode 100644 index 000000000..be249fc04 --- /dev/null +++ b/backend/src/main/java/shook/shook/song/application/ArtistSearchService.java @@ -0,0 +1,119 @@ +package shook.shook.song.application; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shook.shook.song.application.dto.ArtistResponse; +import shook.shook.song.application.dto.ArtistWithSongSearchResponse; +import shook.shook.song.domain.Artist; +import shook.shook.song.domain.InMemoryArtistSynonyms; +import shook.shook.song.domain.Song; +import shook.shook.song.domain.repository.ArtistRepository; +import shook.shook.song.domain.repository.SongRepository; +import shook.shook.song.domain.repository.dto.SongTotalLikeCountDto; +import shook.shook.song.exception.ArtistException; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +@Slf4j +public class ArtistSearchService { + + private static final int TOP_SONG_COUNT_OF_ARTIST = 3; + + private final InMemoryArtistSynonyms inMemoryArtistSynonyms; + private final ArtistRepository artistRepository; + private final SongRepository songRepository; + + public List searchArtistsByKeyword(final String keyword) { + final List artists = findArtistsStartsWithKeyword(keyword); + + return artists.stream() + .map(ArtistResponse::from) + .toList(); + } + + private List findArtistsStartsWithKeyword(final String keyword) { + final List artistsFoundByName = inMemoryArtistSynonyms.findAllArtistsNameStartsWith( + keyword); + final List artistsFoundBySynonym = inMemoryArtistSynonyms.findAllArtistsHavingSynonymStartsWith( + keyword); + + return removeDuplicateArtistResultAndSortByName(artistsFoundByName, artistsFoundBySynonym); + } + + private List removeDuplicateArtistResultAndSortByName(final List firstResult, + final List secondResult) { + return Stream.concat(firstResult.stream(), secondResult.stream()) + .distinct() + .sorted(Comparator.comparing(Artist::getArtistName)) + .toList(); + } + + public List searchArtistsAndTopSongsByKeyword( + final String keyword) { + final List artists = findArtistsStartsOrEndsWithKeyword(keyword); + + return artists.stream() + .map(artist -> ArtistWithSongSearchResponse.of( + artist, + getSongsOfArtistSortedByLikeCount(artist).size(), + getTopSongsOfArtist(artist)) + ) + .toList(); + } + + private List findArtistsStartsOrEndsWithKeyword(final String keyword) { + final List artistsFoundByName = inMemoryArtistSynonyms.findAllArtistsNameStartsOrEndsWith( + keyword); + final List artistsFoundBySynonym = inMemoryArtistSynonyms.findAllArtistsHavingSynonymStartsOrEndsWith( + keyword); + + return removeDuplicateArtistResultAndSortByName(artistsFoundByName, artistsFoundBySynonym); + } + + private List getTopSongsOfArtist(final Artist artist) { + final List songs = getSongsOfArtistSortedByLikeCount(artist); + if (songs.size() < TOP_SONG_COUNT_OF_ARTIST) { + return songs; + } + + return songs.subList(0, TOP_SONG_COUNT_OF_ARTIST); + } + + private List getSongsOfArtistSortedByLikeCount(final Artist artist) { + final List all = songRepository.findAll(); + log.info("all song: {}", all); + final List songsWithTotalLikeCount = songRepository.findAllSongsWithTotalLikeCountByArtist( + artist); + + log.info("found song of artist: {}", songsWithTotalLikeCount); + return songsWithTotalLikeCount.stream() + .sorted(Comparator.comparing(SongTotalLikeCountDto::getTotalLikeCount, + Comparator.reverseOrder()) + .thenComparing(songWithTotalLikeCount -> songWithTotalLikeCount.getSong().getId(), + Comparator.reverseOrder()) + ) + .map(SongTotalLikeCountDto::getSong) + .toList(); + } + + public ArtistWithSongSearchResponse searchAllSongsByArtist(final long artistId) { + final Artist artist = findArtistById(artistId); + final List songs = getSongsOfArtistSortedByLikeCount(artist); + + return ArtistWithSongSearchResponse.of(artist, songs.size(), songs); + } + + private Artist findArtistById(final long artistId) { + return artistRepository.findById(artistId) + .orElseThrow(() -> new ArtistException.NotExistException( + Map.of("ArtistId", String.valueOf(artistId)) + )); + } +} diff --git a/backend/src/main/java/shook/shook/song/application/dto/ArtistResponse.java b/backend/src/main/java/shook/shook/song/application/dto/ArtistResponse.java new file mode 100644 index 000000000..b710f705c --- /dev/null +++ b/backend/src/main/java/shook/shook/song/application/dto/ArtistResponse.java @@ -0,0 +1,30 @@ +package shook.shook.song.application.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import shook.shook.song.domain.Artist; + +@Schema(description = "아티스트를 통한 아티스트, 해당 아티스트의 노래 검색 결과") +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class ArtistResponse { + + @Schema(description = "아티스트 id", example = "1") + private final Long id; + + @Schema(description = "가수 이름", example = "가수") + private final String singer; + + @Schema(description = "가수 대표 이미지 url", example = "https://image.com/artist-profile.jpg") + private final String profileImageUrl; + + public static ArtistResponse from(final Artist artist) { + return new ArtistResponse( + artist.getId(), + artist.getArtistName(), + artist.getProfileImageUrl() + ); + } +} diff --git a/backend/src/main/java/shook/shook/song/application/dto/ArtistWithSongSearchResponse.java b/backend/src/main/java/shook/shook/song/application/dto/ArtistWithSongSearchResponse.java new file mode 100644 index 000000000..a495dce4f --- /dev/null +++ b/backend/src/main/java/shook/shook/song/application/dto/ArtistWithSongSearchResponse.java @@ -0,0 +1,47 @@ +package shook.shook.song.application.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import shook.shook.song.domain.Artist; +import shook.shook.song.domain.Song; + +@Schema(description = "아티스트를 통한 아티스트, 해당 아티스트의 노래 검색 결과") +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class ArtistWithSongSearchResponse { + + @Schema(description = "아티스트 id", example = "1") + private final Long id; + + @Schema(description = "가수 이름", example = "가수") + private final String singer; + + @Schema(description = "가수 대표 이미지 url", example = "https://image.com/artist-profile.jpg") + private final String profileImageUrl; + + @Schema(description = "가수 노래 총 개수", example = "10") + private final int totalSongCount; + + @Schema(description = "아티스트의 노래 목록") + private final List songs; + + public static ArtistWithSongSearchResponse of(final Artist artist, final int totalSongCount, + final List songs) { + return new ArtistWithSongSearchResponse( + artist.getId(), + artist.getArtistName(), + artist.getProfileImageUrl(), + totalSongCount, + convertToSongSearchResponse(songs) + ); + } + + private static List convertToSongSearchResponse(final List songs) { + return songs.stream() + .map(SongSearchResponse::from) + .toList(); + } +} diff --git a/backend/src/main/java/shook/shook/song/application/dto/SongSearchResponse.java b/backend/src/main/java/shook/shook/song/application/dto/SongSearchResponse.java new file mode 100644 index 000000000..7eb66a45a --- /dev/null +++ b/backend/src/main/java/shook/shook/song/application/dto/SongSearchResponse.java @@ -0,0 +1,30 @@ +package shook.shook.song.application.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import shook.shook.song.domain.Song; + +@Schema(description = "검색 결과 (가수, 가수의 노래) 응답") +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class SongSearchResponse { + + @Schema(description = "노래 id", example = "1") + private final Long id; + + @Schema(description = "노래 제목", example = "제목") + private final String title; + + @Schema(description = "노래 앨범 커버 이미지 url", example = "https://image.com/album-cover.jpg") + private final String albumCoverUrl; + + @Schema(description = "노래 비디오 길이", example = "247") + private final int videoLength; + + public static SongSearchResponse from(final Song song) { + return new SongSearchResponse(song.getId(), song.getTitle(), song.getAlbumCoverUrl(), + song.getLength()); + } +} diff --git a/backend/src/main/java/shook/shook/song/domain/Artist.java b/backend/src/main/java/shook/shook/song/domain/Artist.java index 88d181e54..7e7412247 100644 --- a/backend/src/main/java/shook/shook/song/domain/Artist.java +++ b/backend/src/main/java/shook/shook/song/domain/Artist.java @@ -44,10 +44,22 @@ public Artist(final ProfileImageUrl profileImageUrl, final ArtistName artistName this.artistName = artistName; } + public boolean nameStartsWith(final String keyword) { + return artistName.startsWithIgnoringCaseAndWhiteSpace(keyword); + } + + public boolean nameEndsWith(final String keyword) { + return artistName.endsWithIgnoringCaseAndWhiteSpace(keyword); + } + public String getArtistName() { return artistName.getValue(); } + public String getProfileImageUrl() { + return profileImageUrl.getValue(); + } + @Override public boolean equals(final Object o) { if (this == o) { diff --git a/backend/src/main/java/shook/shook/song/domain/ArtistName.java b/backend/src/main/java/shook/shook/song/domain/ArtistName.java index e6830ea2f..d3893a7e1 100644 --- a/backend/src/main/java/shook/shook/song/domain/ArtistName.java +++ b/backend/src/main/java/shook/shook/song/domain/ArtistName.java @@ -17,6 +17,7 @@ public class ArtistName { private static final int NAME_MAXIMUM_LENGTH = 50; + private static final String BLANK = "\\s"; @Column(name = "name", length = 50, nullable = false) private String value; @@ -36,4 +37,32 @@ private void validateName(final String value) { ); } } + + public boolean startsWithIgnoringCaseAndWhiteSpace(final String keyword) { + final String targetKeyword = toLowerCaseRemovingWhiteSpace(keyword); + if (StringChecker.isNullOrBlank(targetKeyword)) { + return false; + } + + return toLowerCaseRemovingWhiteSpace(value) + .startsWith(toLowerCaseRemovingWhiteSpace(keyword)); + } + + public boolean endsWithIgnoringCaseAndWhiteSpace(final String keyword) { + final String targetKeyword = toLowerCaseRemovingWhiteSpace(keyword); + if (StringChecker.isNullOrBlank(targetKeyword)) { + return false; + } + + return toLowerCaseRemovingWhiteSpace(value) + .endsWith(toLowerCaseRemovingWhiteSpace(keyword)); + } + + private String toLowerCaseRemovingWhiteSpace(final String word) { + return removeAllWhiteSpace(word).toLowerCase(); + } + + private String removeAllWhiteSpace(final String word) { + return word.replaceAll(BLANK, ""); + } } diff --git a/backend/src/main/java/shook/shook/song/domain/ArtistSynonym.java b/backend/src/main/java/shook/shook/song/domain/ArtistSynonym.java index 1822f5b4b..6fb63223f 100644 --- a/backend/src/main/java/shook/shook/song/domain/ArtistSynonym.java +++ b/backend/src/main/java/shook/shook/song/domain/ArtistSynonym.java @@ -25,19 +25,28 @@ public class ArtistSynonym { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "artist_id", foreignKey = @ForeignKey(name = "none"), updatable = false, nullable = false) private Artist artist; @Embedded private Synonym synonym; - public String getArtistName() { - return artist.getArtistName(); + public ArtistSynonym(final Artist artist, final Synonym synonym) { + this.artist = artist; + this.synonym = synonym; + } + + public boolean startsWith(final String keyword) { + return synonym.startsWithIgnoringCaseAndWhiteSpace(keyword); } - public String getSynonym() { - return synonym.getValue(); + public boolean endsWith(final String keyword) { + return synonym.endsWithIgnoringCaseAndWhiteSpace(keyword); + } + + public String getArtistName() { + return artist.getArtistName(); } @Override diff --git a/backend/src/main/java/shook/shook/song/domain/InMemoryArtistSynonyms.java b/backend/src/main/java/shook/shook/song/domain/InMemoryArtistSynonyms.java new file mode 100644 index 000000000..3553d809b --- /dev/null +++ b/backend/src/main/java/shook/shook/song/domain/InMemoryArtistSynonyms.java @@ -0,0 +1,72 @@ +package shook.shook.song.domain; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.BiPredicate; +import java.util.stream.Stream; +import org.springframework.stereotype.Repository; + +@Repository +public class InMemoryArtistSynonyms { + + private Map artistsBySynonym = new HashMap<>(); + + public void initialize(final Map artistsBySynonym) { + this.artistsBySynonym = new HashMap<>(artistsBySynonym); + } + + public List findAllArtistsHavingSynonymStartsOrEndsWith(final String keyword) { + return Stream.concat( + findAllArtistsHavingSynonymStartsWith(keyword).stream(), + findAllArtistsHavingSynonymEndsWith(keyword).stream()) + .distinct() + .toList(); + } + + public List findAllArtistsHavingSynonymStartsWith(final String keyword) { + return filterBySynonymCondition(keyword, ArtistSynonym::startsWith); + } + + private List filterBySynonymCondition(final String keyword, + final BiPredicate filter) { + return artistsBySynonym.entrySet().stream() + .filter(entry -> filter.test(entry.getKey(), keyword)) + .map(Entry::getValue) + .distinct() + .toList(); + } + + private List findAllArtistsHavingSynonymEndsWith(final String keyword) { + return filterBySynonymCondition(keyword, ArtistSynonym::endsWith); + } + + public List findAllArtistsNameStartsOrEndsWith(final String keyword) { + return Stream.concat( + findAllArtistsNameStartsWith(keyword).stream(), + findAllArtistsNameEndsWith(keyword).stream()) + .distinct() + .toList(); + } + + public List findAllArtistsNameStartsWith(final String keyword) { + return filterByNameCondition(keyword, Artist::nameStartsWith); + } + + private List filterByNameCondition(final String keyword, + final BiPredicate filter) { + return artistsBySynonym.values().stream() + .filter(artist -> filter.test(artist, keyword)) + .distinct() + .toList(); + } + + private List findAllArtistsNameEndsWith(final String keyword) { + return filterByNameCondition(keyword, Artist::nameEndsWith); + } + + public Map getArtistsBySynonym() { + return new HashMap<>(artistsBySynonym); + } +} diff --git a/backend/src/main/java/shook/shook/song/domain/InMemoryArtistSynonymsGenerator.java b/backend/src/main/java/shook/shook/song/domain/InMemoryArtistSynonymsGenerator.java new file mode 100644 index 000000000..f3e2f41ea --- /dev/null +++ b/backend/src/main/java/shook/shook/song/domain/InMemoryArtistSynonymsGenerator.java @@ -0,0 +1,32 @@ +package shook.shook.song.domain; + +import jakarta.annotation.PostConstruct; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import shook.shook.song.domain.repository.ArtistSynonymRepository; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +@Component +public class InMemoryArtistSynonymsGenerator { + + private final InMemoryArtistSynonyms artistSynonyms; + private final ArtistSynonymRepository artistSynonymRepository; + + @PostConstruct + public void initialize() { + log.info("Initialize ArtistWithSynonym"); + final List synonymsWithArtist = artistSynonymRepository.findAll(); + final Map artistsBySynonym = new HashMap<>(); + for (final ArtistSynonym artistSynonym : synonymsWithArtist) { + artistsBySynonym.put(artistSynonym, artistSynonym.getArtist()); + } + artistSynonyms.initialize(artistsBySynonym); + } +} diff --git a/backend/src/main/java/shook/shook/song/domain/Synonym.java b/backend/src/main/java/shook/shook/song/domain/Synonym.java index 7d9a68ac1..450e197a3 100644 --- a/backend/src/main/java/shook/shook/song/domain/Synonym.java +++ b/backend/src/main/java/shook/shook/song/domain/Synonym.java @@ -17,6 +17,7 @@ public class Synonym { private static final int MAXIMUM_LENGTH = 255; + private static final String BLANK = "\\s"; @Column(name = "synonym", nullable = false) private String value; @@ -36,4 +37,32 @@ private void validate(final String value) { ); } } + + public boolean startsWithIgnoringCaseAndWhiteSpace(final String keyword) { + final String targetKeyword = toLowerCaseRemovingWhiteSpace(keyword); + if (StringChecker.isNullOrBlank(targetKeyword)) { + return false; + } + + return toLowerCaseRemovingWhiteSpace(value) + .startsWith(targetKeyword); + } + + public boolean endsWithIgnoringCaseAndWhiteSpace(final String keyword) { + final String targetKeyword = toLowerCaseRemovingWhiteSpace(keyword); + if (StringChecker.isNullOrBlank(targetKeyword)) { + return false; + } + + return toLowerCaseRemovingWhiteSpace(value) + .endsWith(toLowerCaseRemovingWhiteSpace(targetKeyword)); + } + + private String toLowerCaseRemovingWhiteSpace(final String word) { + return removeAllWhiteSpace(word).toLowerCase(); + } + + private String removeAllWhiteSpace(final String word) { + return word.replaceAll(BLANK, ""); + } } diff --git a/backend/src/main/java/shook/shook/song/domain/repository/ArtistRepository.java b/backend/src/main/java/shook/shook/song/domain/repository/ArtistRepository.java index e8c625a5e..ded9e3822 100644 --- a/backend/src/main/java/shook/shook/song/domain/repository/ArtistRepository.java +++ b/backend/src/main/java/shook/shook/song/domain/repository/ArtistRepository.java @@ -1,8 +1,10 @@ package shook.shook.song.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; import shook.shook.song.domain.Artist; +@Repository public interface ArtistRepository extends JpaRepository { } diff --git a/backend/src/main/java/shook/shook/song/domain/repository/ArtistSynonymRepository.java b/backend/src/main/java/shook/shook/song/domain/repository/ArtistSynonymRepository.java new file mode 100644 index 000000000..c8f60effc --- /dev/null +++ b/backend/src/main/java/shook/shook/song/domain/repository/ArtistSynonymRepository.java @@ -0,0 +1,10 @@ +package shook.shook.song.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import shook.shook.song.domain.ArtistSynonym; + +@Repository +public interface ArtistSynonymRepository extends JpaRepository { + +} 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 fca5fd529..a0c09ce1f 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 @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import shook.shook.song.domain.Artist; import shook.shook.song.domain.Song; import shook.shook.song.domain.SongTitle; import shook.shook.song.domain.repository.dto.SongTotalLikeCountDto; @@ -47,4 +48,12 @@ List findSongsWithMoreLikeCountThanSongWithId( ); boolean existsSongByTitle(final SongTitle title); + + @Query("SELECT s AS song, SUM(COALESCE(kp.likeCount, 0)) AS totalLikeCount " + + "FROM Song s LEFT JOIN s.killingParts.killingParts kp " + + "WHERE s.artist = :artist " + + "GROUP BY s.id") + List findAllSongsWithTotalLikeCountByArtist( + @Param("artist") final Artist artist + ); } diff --git a/backend/src/main/java/shook/shook/song/exception/ArtistException.java b/backend/src/main/java/shook/shook/song/exception/ArtistException.java index e1a0cbd34..ee73f0188 100644 --- a/backend/src/main/java/shook/shook/song/exception/ArtistException.java +++ b/backend/src/main/java/shook/shook/song/exception/ArtistException.java @@ -82,4 +82,15 @@ public TooLongSynonymException(final Map inputValuesByProperty) super(ErrorCode.TOO_LONG_ARTIST_SYNONYM, inputValuesByProperty); } } + + public static class NotExistException extends ArtistException { + + public NotExistException() { + super(ErrorCode.ARTIST_NOT_EXIST); + } + + public NotExistException(final Map inputValuesByProperty) { + super(ErrorCode.ARTIST_NOT_EXIST, inputValuesByProperty); + } + } } diff --git a/backend/src/main/java/shook/shook/song/ui/ArtistSongSearchController.java b/backend/src/main/java/shook/shook/song/ui/ArtistSongSearchController.java new file mode 100644 index 000000000..d7128995f --- /dev/null +++ b/backend/src/main/java/shook/shook/song/ui/ArtistSongSearchController.java @@ -0,0 +1,44 @@ +package shook.shook.song.ui; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.ArtistSearchService; +import shook.shook.song.application.dto.ArtistResponse; +import shook.shook.song.application.dto.ArtistWithSongSearchResponse; +import shook.shook.song.ui.openapi.ArtistSongSearchApi; + +@RequiredArgsConstructor +@RequestMapping("/singers") +@RestController +@Slf4j +public class ArtistSongSearchController implements ArtistSongSearchApi { + + private final ArtistSearchService artistSearchService; + + @GetMapping(params = {"name", "search=singer,song"}) + public ResponseEntity> searchArtistWithSongByKeyword( + @RequestParam(name = "search") final List searchTypes, + @RequestParam(name = "name") final String name) { + return ResponseEntity.ok(artistSearchService.searchArtistsAndTopSongsByKeyword(name)); + } + + @GetMapping(params = {"name", "search=singer"}) + public ResponseEntity> searchArtistByKeyword( + @RequestParam(name = "search") final String search, + @RequestParam(name = "name") final String name) { + return ResponseEntity.ok(artistSearchService.searchArtistsByKeyword(name)); + } + + @GetMapping("/{artist_id}") + public ResponseEntity searchSongsByArtist( + @PathVariable(name = "artist_id") final Long artistId) { + return ResponseEntity.ok(artistSearchService.searchAllSongsByArtist(artistId)); + } +} diff --git a/backend/src/main/java/shook/shook/song/ui/openapi/ArtistSongSearchApi.java b/backend/src/main/java/shook/shook/song/ui/openapi/ArtistSongSearchApi.java new file mode 100644 index 000000000..23be2bdee --- /dev/null +++ b/backend/src/main/java/shook/shook/song/ui/openapi/ArtistSongSearchApi.java @@ -0,0 +1,78 @@ +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.media.Schema; +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.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import shook.shook.song.application.dto.ArtistResponse; +import shook.shook.song.application.dto.ArtistWithSongSearchResponse; + +@Tag(name = "Artist Search", description = "가수 이름 검색 API") +public interface ArtistSongSearchApi { + + @Operation( + summary = "검색어 입력 시 자동 완성되는 가수의 정보 검색", + description = "검색어 입력 시, 검색어로 시작하는 가수의 정보를 검색한다." + ) + @ApiResponse( + responseCode = "200", + description = "가수 검색 성공" + ) + @GetMapping(value = "?search=artist", params = {"name"}) + ResponseEntity> searchArtistByKeyword( + @Parameter(name = "search", description = "검색 타입", + schema = @Schema(enumAsRef = true, allowableValues = {"artist"})) + @RequestParam(name = "search") final String search, + @Parameter( + name = "name", + description = "검색할 가수 키워드", + required = true + ) + @RequestParam(name = "name") final String name + ); + + @Operation( + summary = "검색 시, 검색 결과 조회", + description = "검색 시, 검색어로 시작하거나 끝나는 가수와 해당 가수의 TOP3 노래가 조회된다." + ) + @ApiResponse( + responseCode = "200", + description = "가수, TOP3 노래 검색 성공" + ) + @GetMapping(value = "?search=artist,song", params = {"name"}) + ResponseEntity> searchArtistWithSongByKeyword( + @Parameter(name = "search", description = "검색 타입", + schema = @Schema(enumAsRef = true, allowableValues = {"artist", "song"})) + @RequestParam(name = "search") final List searchTypes, + @Parameter( + name = "name", + description = "검색할 가수 키워드", + required = true + ) + @RequestParam(name = "name") final String name + ); + + @Operation( + summary = "특정 가수의 모든 노래 조회", + description = "가수의 모든 노래를 좋아요 순으로 조회한다." + ) + @ApiResponse( + responseCode = "200", + description = "가수 정보, 노래 목록 검색 성공" + ) + @Parameter( + name = "artist_id", + description = "가수 id", + required = true + ) + @GetMapping("/{artist_id}") + ResponseEntity searchSongsByArtist( + @PathVariable(name = "artist_id") final Long artistId + ); +} diff --git a/backend/src/test/java/shook/shook/song/application/ArtistSearchServiceTest.java b/backend/src/test/java/shook/shook/song/application/ArtistSearchServiceTest.java new file mode 100644 index 000000000..36230cff2 --- /dev/null +++ b/backend/src/test/java/shook/shook/song/application/ArtistSearchServiceTest.java @@ -0,0 +1,238 @@ +package shook.shook.song.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.jdbc.Sql; +import shook.shook.member.domain.Member; +import shook.shook.member.domain.repository.MemberRepository; +import shook.shook.part.domain.PartLength; +import shook.shook.song.application.dto.ArtistResponse; +import shook.shook.song.application.dto.ArtistWithSongSearchResponse; +import shook.shook.song.domain.Artist; +import shook.shook.song.domain.ArtistSynonym; +import shook.shook.song.domain.Genre; +import shook.shook.song.domain.InMemoryArtistSynonyms; +import shook.shook.song.domain.KillingParts; +import shook.shook.song.domain.Song; +import shook.shook.song.domain.Synonym; +import shook.shook.song.domain.killingpart.KillingPart; +import shook.shook.song.domain.killingpart.KillingPartLike; +import shook.shook.song.domain.killingpart.repository.KillingPartLikeRepository; +import shook.shook.song.domain.killingpart.repository.KillingPartRepository; +import shook.shook.song.domain.repository.ArtistRepository; +import shook.shook.song.domain.repository.SongRepository; +import shook.shook.song.exception.ArtistException; +import shook.shook.support.UsingJpaTest; + +@Sql("classpath:/killingpart/initialize_killing_part_song.sql") +class ArtistSearchServiceTest extends UsingJpaTest { + + private ArtistSearchService artistSearchService; + private InMemoryArtistSynonyms artistSynonyms = new InMemoryArtistSynonyms(); + + @Autowired + private SongRepository songRepository; + + @Autowired + private ArtistRepository artistRepository; + + + @Autowired + private KillingPartLikeRepository likeRepository; + + @Autowired + private KillingPartRepository killingPartRepository; + + @Autowired + private MemberRepository memberRepository; + + @BeforeEach + void setUp() { + artistSearchService = new ArtistSearchService(artistSynonyms, artistRepository, + songRepository); + final Song firstSong = songRepository.findById(1L).get(); + final Song secondSong = songRepository.findById(2L).get(); + final Song thirdSong = songRepository.findById(3L).get(); + final Song fourthSong = songRepository.findById(4L).get(); + final Member member = memberRepository.findById(1L).get(); + + addLikeToEachKillingParts(secondSong, member); + addLikeToEachKillingParts(thirdSong, member); + + initializeAllArtistsWithSynonyms(); + } + + private void addLikeToEachKillingParts(final Song song, final Member member) { + for (final KillingPart killingPart : song.getKillingParts()) { + final KillingPartLike like = new KillingPartLike(killingPart, member); + killingPart.like(like); + likeRepository.save(like); + } + } + + private void initializeAllArtistsWithSynonyms() { + final Artist newJeans = artistRepository.findById(1L).get(); + final Artist 가수 = artistRepository.findById(2L).get(); + final Artist 정국 = artistRepository.findById(3L).get(); + + artistSynonyms.initialize( + Map.of( + new ArtistSynonym(newJeans, new Synonym("뉴진스")), newJeans, + new ArtistSynonym(가수, new Synonym("인기가수")), 가수, + new ArtistSynonym(정국, new Synonym("방탄인기")), 정국 + ) + ); + } + + @DisplayName("동의어 또는 이름이 키워드로 시작하는 아티스트 목록을 가나다 순으로 정렬하여 검색한다.") + @Test + void searchArtistsByKeyword() { + // given + // when + final List artists = artistSearchService.searchArtistsByKeyword("인기"); + + // then + final Artist artist = artistRepository.findById(2L).get(); + + assertThat(artists).usingRecursiveComparison() + .isEqualTo(List.of(ArtistResponse.from(artist))); + } + + @DisplayName("아티스 목록을 모두 조회할 때 키워드가 비어있다면 빈 결과를 반환한다.") + @Test + void searchArtistsByKeyword_emptyKeyword() { + // given + // when + final List artists = artistSearchService.searchArtistsByKeyword(" "); + + // then + assertThat(artists).isEmpty(); + } + + @DisplayName("키워드로 시작하거나 끝나는 아티스트를 가나다 순으로 정렬, 해당 아티스트의 TOP 곡 목록을 모두 조회한다.") + @ParameterizedTest + @ValueSource(strings = {"국", "방탄"}) + void searchArtistsAndTopSongsByKeyword(final String keyword) { + // given + final Artist artist = artistRepository.findById(3L).get(); + final Song anotherSong1 = createNewSongWithArtist(artist); + final Song anotherSong2 = createNewSongWithArtist(artist); + final Song anotherSong3 = createNewSongWithArtist(artist); + saveSong(anotherSong1); + saveSong(anotherSong2); + saveSong(anotherSong3); + + addLikeToEachKillingParts(anotherSong1, memberRepository.findById(1L).get()); + // 예상 TOP3: anotherSong1, anotherSong3, anotherSong2 + saveAndClearEntityManager(); + + // when + final List artistsWithSong = artistSearchService.searchArtistsAndTopSongsByKeyword( + keyword); + + // then + final Artist foundArtist = artistRepository.findById(3L).get(); + final Song expectedFirstSong = songRepository.findById(anotherSong1.getId()).get(); + final Song expectedThirdSong = songRepository.findById(anotherSong3.getId()).get(); + final Song expectedSecondSong = songRepository.findById(anotherSong2.getId()).get(); + + assertThat(artistsWithSong).usingRecursiveComparison() + .isEqualTo(List.of(ArtistWithSongSearchResponse.of( + foundArtist, + 4, + List.of(expectedFirstSong, expectedThirdSong, expectedSecondSong)) + )); + } + + private void saveSong(final Song song) { + songRepository.save(song); + killingPartRepository.saveAll(song.getKillingParts()); + } + + private Song createNewSongWithArtist(final Artist artist) { + final KillingPart firstKillingPart = KillingPart.forSave(10, PartLength.SHORT); + final KillingPart secondKillingPart = KillingPart.forSave(15, PartLength.SHORT); + final KillingPart thirdKillingPart = KillingPart.forSave(20, PartLength.SHORT); + + return new Song( + "title", + "3rUPND6FG8A", + "image_url", + artist, + 230, + Genre.from("댄스"), + new KillingParts(List.of(firstKillingPart, secondKillingPart, thirdKillingPart)) + ); + } + + @DisplayName("아티스트, 해당 아티스트의 TOP 곡 목록을 모두 조회할 때 키워드가 비어있다면 빈 결과를 반환한다.") + @Test + void searchArtistsAndTopSongsByKeyword_emptyKeyword() { + // given + // when + final List response = artistSearchService.searchArtistsAndTopSongsByKeyword( + " "); + + // then + assertThat(response).isEmpty(); + } + + @DisplayName("아티스트의 모든 곡 목록을 좋아요 순으로 정렬하여 조회한다.") + @Test + void searchAllSongsByArtist() { + // given + final Artist artist = artistRepository.findById(3L).get(); + final Song anotherSong1 = createNewSongWithArtist(artist); + final Song anotherSong2 = createNewSongWithArtist(artist); + final Song anotherSong3 = createNewSongWithArtist(artist); + saveSong(anotherSong1); + saveSong(anotherSong2); + saveSong(anotherSong3); + + addLikeToEachKillingParts(anotherSong1, memberRepository.findById(1L).get()); + // 예상 TOP3: anotherSong1, anotherSong3, anotherSong2, 4L Song + saveAndClearEntityManager(); + + // when + final ArtistWithSongSearchResponse artistSongsResponse = artistSearchService.searchAllSongsByArtist( + artist.getId()); + + // then + final Artist expectedArtist = artistRepository.findById(artist.getId()).get(); + final Song expectedFirstSong = songRepository.findById(anotherSong1.getId()).get(); + final Song expectedSecondSong = songRepository.findById(anotherSong3.getId()).get(); + final Song expectedThirdSong = songRepository.findById(anotherSong2.getId()).get(); + final Song expectedFourthSong = songRepository.findById(4L).get(); + + assertThat(artistSongsResponse).usingRecursiveComparison() + .isEqualTo(ArtistWithSongSearchResponse.of( + expectedArtist, + 4, + List.of( + expectedFirstSong, + expectedSecondSong, + expectedThirdSong, + expectedFourthSong) + )); + } + + @DisplayName("존재하지 않는 아티스트를 요청하면 예외가 발생한다.") + @Test + void searchAllSongsByArtist_artistNotExist() { + // given + final Long artistIdNotExist = Long.MAX_VALUE; + + // when, then + assertThatThrownBy(() -> artistSearchService.searchAllSongsByArtist(artistIdNotExist)) + .isInstanceOf(ArtistException.NotExistException.class); + } +} diff --git a/backend/src/test/java/shook/shook/song/domain/ArtistNameTest.java b/backend/src/test/java/shook/shook/song/domain/ArtistNameTest.java index b0770c3cc..5bb90c9f4 100644 --- a/backend/src/test/java/shook/shook/song/domain/ArtistNameTest.java +++ b/backend/src/test/java/shook/shook/song/domain/ArtistNameTest.java @@ -1,11 +1,13 @@ package shook.shook.song.domain; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; import shook.shook.song.exception.ArtistException; @@ -44,4 +46,49 @@ void create_fail_lengthOver50() { assertThatThrownBy(() -> new ArtistName(name)) .isInstanceOf(ArtistException.TooLongNameException.class); } + + @DisplayName("입력값으로 시작하는 가수 이름이라면 true, 아니면 false 를 반환한다. (대소문자, 공백 제거)") + @ParameterizedTest + @CsvSource(value = {"Hi:true", "H i:true", "HiYou:true", " Hi:true", + "Hello:false"}, delimiter = ':') + void startsWithIgnoringCaseAndWhiteSpace(final String value, final boolean isSame) { + // given + final String keyword = "hi"; + final ArtistName artistName = new ArtistName(value); + + // when + final boolean result = artistName.startsWithIgnoringCaseAndWhiteSpace(keyword); + + // then + assertThat(result).isEqualTo(isSame); + } + + @DisplayName("입력값으로 끝나는 가수 이름이라면 true, 아니면 false 를 반환한다. (대소문자, 공백 제거)") + @ParameterizedTest + @CsvSource(value = {"HelloHi:true", "H i:true", " Hi :true", "Hello:false"}, delimiter = ':') + void endsWithIgnoringCaseAndWhiteSpace(final String value, final boolean isSame) { + // given + final String keyword = "hi"; + final ArtistName artistName = new ArtistName(value); + + // when + final boolean result = artistName.endsWithIgnoringCaseAndWhiteSpace(keyword); + + // then + assertThat(result).isEqualTo(isSame); + } + + @DisplayName("입력값이 비어있다면 false 가 반환된다.") + @Test + void ignoringCaseAndWhiteSpace_emptyValue() { + // given + final String keyword = " "; + final ArtistName artistName = new ArtistName("hi"); + + // when + final boolean result = artistName.endsWithIgnoringCaseAndWhiteSpace(keyword); + + // then + assertThat(result).isFalse(); + } } diff --git a/backend/src/test/java/shook/shook/song/domain/InMemoryArtistSynonymsTest.java b/backend/src/test/java/shook/shook/song/domain/InMemoryArtistSynonymsTest.java new file mode 100644 index 000000000..f048698ac --- /dev/null +++ b/backend/src/test/java/shook/shook/song/domain/InMemoryArtistSynonymsTest.java @@ -0,0 +1,164 @@ +package shook.shook.song.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +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.transaction.annotation.Transactional; +import shook.shook.song.domain.repository.ArtistRepository; +import shook.shook.song.domain.repository.ArtistSynonymRepository; + +@SpringBootTest +@Transactional +class InMemoryArtistSynonymsTest { + + private Artist artist1; + private Artist artist2; + private ArtistSynonym synonym1; + private ArtistSynonym synonym2; + + @Autowired + private ArtistSynonymRepository artistSynonymRepository; + + @Autowired + private ArtistRepository artistRepository; + + @Autowired + private InMemoryArtistSynonyms artistSynonyms; + + @Autowired + private InMemoryArtistSynonymsGenerator generator; + + @BeforeEach + void setUp() { + artist1 = new Artist(new ProfileImageUrl("image"), new ArtistName("name1")); + artist2 = new Artist(new ProfileImageUrl("image"), new ArtistName("name2")); + artistRepository.saveAll(List.of(artist1, artist2)); + + synonym1 = new ArtistSynonym(artist1, new Synonym("synonym1")); + synonym2 = new ArtistSynonym(artist2, new Synonym("synonym2")); + artistSynonymRepository.saveAll(List.of(synonym1, synonym2)); + } + + @DisplayName("InMemoryArtistSynonymsGenerator 에 의해 InMemoryArtistSynonyms 가 초기화된다.") + @Test + void generator_initialize() { + // given + // when + generator.initialize(); + + // then + assertThat(artistSynonyms.getArtistsBySynonym()).containsExactlyInAnyOrderEntriesOf( + Map.of(synonym1, artist1, synonym2, artist2) + ); + } + + @DisplayName("입력된 값으로 시작하거나 끝나는 동의어를 가진 아티스트를 모두 찾는다.") + @Test + void findAllArtistsHavingSynonymStartsOrEndsWith() { + // given + final Artist newArtist = new Artist(new ProfileImageUrl("image"), + new ArtistName("newName")); + final ArtistSynonym newSynonym = new ArtistSynonym(newArtist, new Synonym("newTestSy")); + artistRepository.save(newArtist); + artistSynonymRepository.save(newSynonym); + + generator.initialize(); + + // when + final List result = artistSynonyms.findAllArtistsHavingSynonymStartsOrEndsWith( + "sy"); + + // then + assertThat(result).containsExactlyInAnyOrder(artist1, artist2, newArtist); + } + + @DisplayName("입력된 값으로 시작하는 동의어를 가진 아티스트를 모두 찾는다.") + @Test + void findAllArtistsHavingSynonymStartsWith() { + // given + final Artist newArtist = new Artist(new ProfileImageUrl("image"), + new ArtistName("newName")); + final ArtistSynonym newSynonym = new ArtistSynonym(newArtist, new Synonym("newTestSy")); + artistRepository.save(newArtist); + artistSynonymRepository.save(newSynonym); + + generator.initialize(); + + // when + final List result = artistSynonyms.findAllArtistsHavingSynonymStartsWith( + "sy"); + + // then + assertThat(result).containsExactlyInAnyOrder(artist1, artist2); + } + + @DisplayName("동의어 검색 시, 입력된 값이 비어있다면 빈 결과가 반환된다.") + @Test + void findAllArtistsHavingSynonymStartsOrEndsWith_emptyInput() { + // given + // when + final List result = artistSynonyms.findAllArtistsHavingSynonymStartsOrEndsWith( + " "); + + // then + assertThat(result).isEmpty(); + } + + @DisplayName("입력된 값으로 시작하거나 끝나는 이름을 가진 아티스트를 모두 찾는다.") + @Test + void findAllArtistsNameStartsOrEndsWith() { + // given + final Artist newArtist = new Artist(new ProfileImageUrl("image"), + new ArtistName("newName")); + final ArtistSynonym newSynonym = new ArtistSynonym(newArtist, new Synonym("newSynonym")); + artistRepository.save(newArtist); + artistSynonymRepository.save(newSynonym); + + generator.initialize(); + + // when + final List result = artistSynonyms.findAllArtistsNameStartsOrEndsWith( + "name"); + + // then + assertThat(result).containsExactlyInAnyOrder(artist1, artist2, newArtist); + } + + @DisplayName("입력된 값으로 시작하는 이름을 가진 아티스트를 모두 찾는다.") + @Test + void findAllArtistsNameStartsWith() { + // given + final Artist newArtist = new Artist(new ProfileImageUrl("image"), + new ArtistName("newName")); + final ArtistSynonym newSynonym = new ArtistSynonym(newArtist, new Synonym("newSynonym")); + artistRepository.save(newArtist); + artistSynonymRepository.save(newSynonym); + + generator.initialize(); + + // when + final List result = artistSynonyms.findAllArtistsNameStartsWith( + "name"); + + // then + assertThat(result).containsExactlyInAnyOrder(artist1, artist2); + } + + @DisplayName("가수명 검색 시, 입력된 값이 비어있다면 빈 결과가 반환된다.") + @Test + void findAllArtistsNameStartsOrEndsWith_emptyInput() { + // given + // when + final List result = artistSynonyms.findAllArtistsNameStartsOrEndsWith( + " "); + + // then + assertThat(result).isEmpty(); + } +} diff --git a/backend/src/test/java/shook/shook/song/domain/SynonymTest.java b/backend/src/test/java/shook/shook/song/domain/SynonymTest.java index ae8eb7d21..5e06baf9d 100644 --- a/backend/src/test/java/shook/shook/song/domain/SynonymTest.java +++ b/backend/src/test/java/shook/shook/song/domain/SynonymTest.java @@ -1,11 +1,13 @@ package shook.shook.song.domain; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; import shook.shook.song.exception.ArtistException; @@ -44,4 +46,49 @@ void create_fail_lengthOver255() { assertThatThrownBy(() -> new Synonym(synonym)) .isInstanceOf(ArtistException.TooLongSynonymException.class); } + + @DisplayName("입력값으로 시작하는 동의어라면 true, 아니면 false 를 반환한다. (대소문자, 공백 제거)") + @ParameterizedTest + @CsvSource(value = {"Hi:true", "H i:true", "HiYou:true", " Hi:true", + "Hello:false"}, delimiter = ':') + void startsWithIgnoringCaseAndWhiteSpace(final String value, final boolean isSame) { + // given + final String keyword = "hi"; + final Synonym synonym = new Synonym(value); + + // when + final boolean result = synonym.startsWithIgnoringCaseAndWhiteSpace(keyword); + + // then + assertThat(result).isEqualTo(isSame); + } + + @DisplayName("입력값으로 끝나는 동의어라면 true, 아니면 false 를 반환한다. (대소문자, 공백 제거)") + @ParameterizedTest + @CsvSource(value = {"HelloHi:true", "H i:true", " Hi :true", "Hello:false"}, delimiter = ':') + void endsWithIgnoringCaseAndWhiteSpace(final String value, final boolean isSame) { + // given + final String keyword = "hi"; + final Synonym synonym = new Synonym(value); + + // when + final boolean result = synonym.endsWithIgnoringCaseAndWhiteSpace(keyword); + + // then + assertThat(result).isEqualTo(isSame); + } + + @DisplayName("입력값이 비어있다면 false 가 반환된다.") + @Test + void ignoringCaseAndWhiteSpace_emptyValue() { + // given + final String keyword = " "; + final Synonym synonym = new Synonym("hi"); + + // when + final boolean result = synonym.endsWithIgnoringCaseAndWhiteSpace(keyword); + + // then + assertThat(result).isFalse(); + } } 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 32bf0dd6b..809098fb3 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 @@ -1,6 +1,7 @@ package shook.shook.song.domain.repository; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; @@ -405,4 +406,39 @@ void findSongsWithMoreLikeCountThanSongWithId_smallData() { findSavedSong(thirdSong)) ); } + + @DisplayName("Artist 의 모든 노래를 좋아요 개수와 함께 조회한다.") + @Test + void findAllSongsWithTotalLikeCountByArtist() { + // given + final Member firstMember = createAndSaveMember("first@naver.com", "first"); + final Member secondMember = createAndSaveMember("second@naver.com", "second"); + final Song firstSong = saveSong(createNewSongWithKillingParts()); + final Song secondSong = saveSong(createNewSongWithKillingParts()); + final Song thirdSong = saveSong(createNewSongWithKillingParts()); + + killingPartRepository.saveAll(firstSong.getKillingParts()); + killingPartRepository.saveAll(secondSong.getKillingParts()); + killingPartRepository.saveAll(thirdSong.getKillingParts()); + + addLikeToKillingPart(firstSong.getKillingParts().get(0), firstMember); + addLikeToKillingPart(firstSong.getKillingParts().get(0), secondMember); + addLikeToKillingPart(firstSong.getKillingParts().get(1), firstMember); + addLikeToKillingPart(firstSong.getKillingParts().get(2), firstMember); + + saveAndClearEntityManager(); + + // when + final Song songToFind = songRepository.findById(firstSong.getId()).get(); + final Artist artistToFind = songToFind.getArtist(); + final List result = songRepository.findAllSongsWithTotalLikeCountByArtist( + artistToFind); + + // then + assertAll( + () -> assertThat(result).hasSize(1), + () -> assertThat(result.get(0).getSong()).isEqualTo(songToFind), + () -> assertThat(result.get(0).getTotalLikeCount()).isEqualTo(4) + ); + } } diff --git a/backend/src/test/java/shook/shook/song/ui/ArtistSongSearchControllerTest.java b/backend/src/test/java/shook/shook/song/ui/ArtistSongSearchControllerTest.java new file mode 100644 index 000000000..0166163eb --- /dev/null +++ b/backend/src/test/java/shook/shook/song/ui/ArtistSongSearchControllerTest.java @@ -0,0 +1,153 @@ +package shook.shook.song.ui; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import io.restassured.RestAssured; +import java.util.List; +import java.util.Map; +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.ArtistResponse; +import shook.shook.song.application.dto.ArtistWithSongSearchResponse; +import shook.shook.song.application.dto.SongSearchResponse; +import shook.shook.song.domain.Artist; +import shook.shook.song.domain.ArtistSynonym; +import shook.shook.song.domain.InMemoryArtistSynonyms; +import shook.shook.song.domain.Synonym; +import shook.shook.song.domain.repository.ArtistRepository; +import shook.shook.song.domain.repository.ArtistSynonymRepository; + +@SuppressWarnings("NonAsciiCharacters") +@Sql("classpath:/killingpart/initialize_killing_part_song.sql") +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class ArtistSongSearchControllerTest { + + private Artist newJeans; + private Artist 가수; + private Artist 정국; + + @LocalServerPort + public int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + + newJeans = artistRepository.findById(1L).get(); + 가수 = artistRepository.findById(2L).get(); + 정국 = artistRepository.findById(3L).get(); + final ArtistSynonym synonym1 = new ArtistSynonym(newJeans, new Synonym("인기뉴진스")); + final ArtistSynonym synonym2 = new ArtistSynonym(가수, new Synonym("인기가수")); + final ArtistSynonym synonym3 = new ArtistSynonym(정국, new Synonym("방탄인기")); + + artistSynonyms.initialize( + Map.of( + synonym1, newJeans, + synonym2, 가수, + synonym3, 정국 + ) + ); + } + + @Autowired + private InMemoryArtistSynonyms artistSynonyms; + + @Autowired + private ArtistSynonymRepository synonymRepository; + + @Autowired + private ArtistRepository artistRepository; + + @DisplayName("search=singer,song name=검색어 으로 요청을 보내는 경우 상태코드 200, 검색어로 시작하거나 끝나는 가수, 가수의 TOP3 노래 리스트를 반환한다.") + @Test + void searchArtistWithSongByKeyword() { + // given + final String searchType = "singer,song"; + final String keyword = "인기"; + + // when + final List response = RestAssured.given().log().all() + .params(Map.of("name", keyword, "search", searchType)) + .when().log().all() + .get("/singers") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .body().jsonPath().getList(".", ArtistWithSongSearchResponse.class); + + // then + assertThat(response).hasSize(3); + final ArtistWithSongSearchResponse firstResponse = response.get(0); + assertThat(firstResponse.getId()).isEqualTo(newJeans.getId()); + assertThat(getSongIdsFromResponse(firstResponse)).containsExactly(3L, 1L); + + final ArtistWithSongSearchResponse secondResponse = response.get(1); + assertThat(secondResponse.getId()).isEqualTo(가수.getId()); + assertThat(getSongIdsFromResponse(secondResponse)).containsExactly(2L); + + final ArtistWithSongSearchResponse thirdResponse = response.get(2); + assertThat(thirdResponse.getId()).isEqualTo(정국.getId()); + assertThat(getSongIdsFromResponse(thirdResponse)).containsExactly(4L); + } + + private List getSongIdsFromResponse(final ArtistWithSongSearchResponse response) { + return response.getSongs() + .stream() + .map(SongSearchResponse::getId) + .toList(); + } + + @DisplayName("search=singer name=검색어 으로 요청을 보내는 경우 상태코드 200, 검색어로 시작하는 가수 목록을 반환한다.") + @Test + void searchArtistByKeyword() { + // given + final String searchType = "singer"; + final String keyword = "인기"; + + // when + final List response = RestAssured.given().log().all() + .params(Map.of("name", keyword, "search", searchType)) + .when().log().all() + .get("/singers") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .body().jsonPath().getList(".", ArtistResponse.class); + + // then + // 가나다 순 정렬, 가수 -> 뉴진스 + assertAll( + () -> assertThat(response).hasSize(2), + () -> assertThat(response.get(0)).hasFieldOrPropertyWithValue("id", newJeans.getId()), + () -> assertThat(response.get(1)).hasFieldOrPropertyWithValue("id", 가수.getId()) + ); + } + + @DisplayName("GET /singers/{singerId} 로 요청을 보내는 경우 상태코드 200, 해당 가수의 정보, 모든 노래 리스트를 반환한다.") + @Test + void searchSongsByArtist() { + // given + final Long singerId = newJeans.getId(); + + // when + final ArtistWithSongSearchResponse response = RestAssured.given().log().all() + .when().log().all() + .get("/singers/{singerId}", singerId) + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .body().as(ArtistWithSongSearchResponse.class); + + // then + assertThat(response.getId()).isEqualTo(newJeans.getId()); + assertThat(getSongIdsFromResponse(response)).containsExactly(3L, 1L); + } +} diff --git a/backend/src/test/java/shook/shook/song/ui/SongSwipeControllerTest.java b/backend/src/test/java/shook/shook/song/ui/SongSwipeControllerTest.java index 8adbbe9f2..7b2a02997 100644 --- a/backend/src/test/java/shook/shook/song/ui/SongSwipeControllerTest.java +++ b/backend/src/test/java/shook/shook/song/ui/SongSwipeControllerTest.java @@ -23,6 +23,7 @@ import shook.shook.song.application.killingpart.dto.HighLikedSongResponse; import shook.shook.song.application.killingpart.dto.KillingPartLikeRequest; +@SuppressWarnings("NonAsciiCharacters") @Sql("classpath:/killingpart/initialize_killing_part_song.sql") @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class SongSwipeControllerTest { @@ -251,7 +252,6 @@ void showPrevSongsWithGenre() { .extract() .body().jsonPath().getList(".", SongResponse.class); - //then //then assertThat(response.stream() .map(SongResponse::getId).toList())