From 6c01cb077ce209048bafbd7bb361837739ac2cc2 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 16:11:28 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=B5=9C=EC=86=8C=20=EA=B8=B8=EC=9D=B4=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trim 후 2자 미만이면 InvalidInputException을 던지도록 PostService.search()에 검증 추가. 엔티티 조회 API(/api/entities/**)를 SecurityConfig permitAll에 추가. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/common/config/SecurityConfig.java | 3 ++- .../post/controller/SearchController.java | 2 +- .../Backend/post/service/PostService.java | 10 +++++++++- .../Backend/post/service/PostServiceTest.java | 18 ++++++++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java index e536576b..8e27d573 100644 --- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java @@ -121,7 +121,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/calendar", "/api/mentions/search", "/api/posts/**", - "/api/search" + "/api/search", + "/api/entities/**" ).permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/auth/register").hasRole("PENDING") diff --git a/src/main/java/com/Coming/Backend/post/controller/SearchController.java b/src/main/java/com/Coming/Backend/post/controller/SearchController.java index a58e9cf4..951a798c 100644 --- a/src/main/java/com/Coming/Backend/post/controller/SearchController.java +++ b/src/main/java/com/Coming/Backend/post/controller/SearchController.java @@ -27,7 +27,7 @@ public class SearchController { private final PostService postService; @Operation(summary = "게시글 통합 검색") - @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백)") + @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백 또는 trim 후 2자 미만)") @GetMapping public ResponseEntity> search( @RequestParam @NotBlank String q, diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index b8c9bc9b..4b7076eb 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -75,6 +75,8 @@ public class PostService { */ private static final int MAX_CONTENT_LENGTH = 50000; + private static final int MIN_SEARCH_QUERY_LENGTH = 2; + /** * 게시글을 생성한다. * entityTags가 가리키는 엔티티의 실존 여부는 검증하지 않는다 — 삭제된 참조와 동일하게 @@ -231,10 +233,16 @@ public List getTrendingTags(int days, int limit) { /** * 게시글 제목·본문·태그된 엔티티명을 통합 검색한다. 최신순 고정. + * + * @param q trim 후 2자 미만이면 InvalidInputException. */ public PageResponse search(String q, int page, int size) { + String trimmedQ = q.trim(); + if (trimmedQ.length() < MIN_SEARCH_QUERY_LENGTH) { + throw new InvalidInputException(); + } Pageable pageable = PageRequest.of(page, size); - String likeQ = "%" + escapeLikeWildcards(q.toLowerCase(Locale.ROOT)) + "%"; + String likeQ = "%" + escapeLikeWildcards(trimmedQ.toLowerCase(Locale.ROOT)) + "%"; Page result = postRepository.searchPosts(likeQ, pageable); List content = toSummaryResponses(result.getContent()); return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index fa25fa8d..50c0aec9 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -659,6 +659,24 @@ void should_escape_like_wildcards_when_search_query_contains_percent_and_undersc verify(postRepository).searchPosts("%50\\%\\_off%", pageable); } + @Test + void should_throw_invalid_input_exception_when_search_query_length_is_less_than_two() { + // when & then + assertThatThrownBy(() -> postService.search("a", 0, 20)) + .isInstanceOf(InvalidInputException.class) + .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + verify(postRepository, never()).searchPosts(any(), any()); + } + + @Test + void should_throw_invalid_input_exception_when_trimmed_search_query_length_is_less_than_two() { + // when & then + assertThatThrownBy(() -> postService.search(" a ", 0, 20)) + .isInstanceOf(InvalidInputException.class) + .hasMessage(ErrorCode.INVALID_INPUT.getMessage()); + verify(postRepository, never()).searchPosts(any(), any()); + } + // ------------------------------------------------------------------------- // update // ------------------------------------------------------------------------- From dd2ed5d6831b2d04add330af28a5898dcd338a30 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 16:37:44 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[feat]=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EB=A9=98=EC=85=98=20=EA=B2=80=EC=83=89=20API=20=EB=AC=B4?= =?UTF-8?q?=ED=95=9C=EC=8A=A4=ED=81=AC=EB=A1=A4=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE 무한스크롤 전환에 맞춰 GET /api/mentions/search에 page 파라미터를 추가하고 limit 기본값을 10 -> 20으로 변경. 응답을 프로젝트 표준 PageResponse ({content, page, size, totalElements, totalPages}) 형식으로 변경. - ConcertRepository.searchByTitleForMention: List -> Page 반환, countQuery 추가, startDate DESC + id DESC tie-breaker로 페이지 간 정렬 안정성 확보 - MentionService: ARTIST/RELEASE 검색에 id 오름차순 정렬을 적용해 동일한 이유로 안정성 확보 - MentionController/Service 테스트를 새 시그니처에 맞게 갱신, 페이지 파라미터 전달과 정렬 적용을 검증하는 케이스 추가 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../concert/repository/ConcertRepository.java | 5 +- .../post/controller/MentionController.java | 14 ++-- .../Backend/post/service/MentionService.java | 45 ++++++------ .../controller/MentionControllerTest.java | 24 ++++--- .../post/service/MentionServiceTest.java | 69 +++++++++++++++---- 5 files changed, 102 insertions(+), 55 deletions(-) diff --git a/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java b/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java index af24102d..559824b5 100644 --- a/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java +++ b/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java @@ -35,8 +35,9 @@ public interface ConcertRepository extends JpaRepository { @Query("UPDATE Concert c SET c.viewCount = c.viewCount + 1 WHERE c.id = :id") void incrementViewCount(@Param("id") Long id); - @Query("SELECT c FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q ORDER BY c.startDate DESC") - List searchByTitleForMention(@Param("hidden") Collection hidden, @Param("q") String q, Pageable pageable); + @Query(value = "SELECT c FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q ORDER BY c.startDate DESC, c.id DESC", + countQuery = "SELECT COUNT(c) FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q") + Page searchByTitleForMention(@Param("hidden") Collection hidden, @Param("q") String q, Pageable pageable); @Query("SELECT c FROM Concert c WHERE c.startDate <= :lastDay AND c.endDate >= :firstDay AND c.status NOT IN :hidden ORDER BY c.startDate ASC") List findByDateRange(@Param("firstDay") LocalDate firstDay, @Param("lastDay") LocalDate lastDay, @Param("hidden") Collection hidden); diff --git a/src/main/java/com/Coming/Backend/post/controller/MentionController.java b/src/main/java/com/Coming/Backend/post/controller/MentionController.java index 68ee3b6a..de08232d 100644 --- a/src/main/java/com/Coming/Backend/post/controller/MentionController.java +++ b/src/main/java/com/Coming/Backend/post/controller/MentionController.java @@ -1,5 +1,6 @@ package com.Coming.Backend.post.controller; +import com.Coming.Backend.common.response.PageResponse; import com.Coming.Backend.post.dto.EntityCardResponse; import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.post.service.MentionService; @@ -17,8 +18,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - @Tag(name = "Post") @Validated @RestController @@ -28,13 +27,14 @@ public class MentionController { private final MentionService mentionService; - @Operation(summary = "엔티티 검색 (게시글 본문 멘션 자동완성)") - @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백 또는 limit 범위 초과)") + @Operation(summary = "엔티티 검색 (게시글 본문 멘션 자동완성, 무한 스크롤)") + @ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백, page 음수 또는 limit 범위 초과)") @GetMapping("/search") - public ResponseEntity> search( + public ResponseEntity> search( @RequestParam EntityType type, @RequestParam @NotBlank String q, - @RequestParam(defaultValue = "10") @Min(1) @Max(20) int limit) { - return ResponseEntity.ok(mentionService.search(type, q, limit)); + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(20) int limit) { + return ResponseEntity.ok(mentionService.search(type, q, page, limit)); } } diff --git a/src/main/java/com/Coming/Backend/post/service/MentionService.java b/src/main/java/com/Coming/Backend/post/service/MentionService.java index 131b844c..b91e4f24 100644 --- a/src/main/java/com/Coming/Backend/post/service/MentionService.java +++ b/src/main/java/com/Coming/Backend/post/service/MentionService.java @@ -2,6 +2,8 @@ import com.Coming.Backend.artist.entity.Artist; import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.concert.entity.Concert; import com.Coming.Backend.concert.entity.ConcertStatus; import com.Coming.Backend.concert.repository.ConcertRepository; import com.Coming.Backend.post.dto.EntityCardResponse; @@ -9,8 +11,10 @@ import com.Coming.Backend.release.entity.ReleaseGroup; import com.Coming.Backend.release.repository.ReleaseGroupRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,40 +39,37 @@ public class MentionService { private final EntityLookupService entityLookupService; /** - * 게시글 본문 멘션 자동완성을 위해 type별로 q에 대소문자 무시 부분 일치하는 엔티티를 최대 limit건 검색한다. + * 게시글 본문 멘션 자동완성을 위해 type별로 q에 대소문자 무시 부분 일치하는 엔티티를 페이지 단위로 검색한다. + * 무한 스크롤 조회를 위해 id를 tie-breaker로 사용해 페이지 간 정렬을 안정적으로 유지한다. */ - public List search(EntityType type, String q, int limit) { - Pageable pageable = PageRequest.of(0, limit); + public PageResponse search(EntityType type, String q, int page, int size) { return switch (type) { - case CONCERT -> searchConcerts(q, pageable); - case ARTIST -> searchArtists(q, pageable); - case RELEASE -> searchReleases(q, pageable); + case CONCERT -> searchConcerts(q, PageRequest.of(page, size)); + case ARTIST -> searchArtists(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))); + case RELEASE -> searchReleases(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))); }; } - private List searchConcerts(String q, Pageable pageable) { + private PageResponse searchConcerts(String q, Pageable pageable) { String likeQ = toLikePattern(q); - return concertRepository.searchByTitleForMention(HIDDEN_STATUSES, likeQ, pageable).stream() - .map(entityLookupService::toCard) - .toList(); + Page concerts = concertRepository.searchByTitleForMention(HIDDEN_STATUSES, likeQ, pageable); + return PageResponse.from(concerts.map(entityLookupService::toCard)); } - private List searchArtists(String q, Pageable pageable) { - return artistRepository.findByNameOrAliasContainingIgnoreCase(q, pageable).stream() - .map(entityLookupService::toCard) - .toList(); + private PageResponse searchArtists(String q, Pageable pageable) { + Page artists = artistRepository.findByNameOrAliasContainingIgnoreCase(q, pageable); + return PageResponse.from(artists.map(entityLookupService::toCard)); } - private List searchReleases(String q, Pageable pageable) { - List releases = releaseGroupRepository - .searchReleases(null, null, null, toLikePattern(q), pageable) - .getContent(); + private PageResponse searchReleases(String q, Pageable pageable) { + Page releases = releaseGroupRepository + .searchReleases(null, null, null, toLikePattern(q), pageable); Map artistNames = artistRepository.findAllById( - releases.stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet()) + releases.getContent().stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet()) ).stream().collect(Collectors.toMap(Artist::getId, Artist::getName)); - return releases.stream() - .map(release -> entityLookupService.toCard(release, artistNames.get(release.getArtistId()))) - .toList(); + return PageResponse.from( + releases.map(release -> entityLookupService.toCard(release, artistNames.get(release.getArtistId()))) + ); } private String toLikePattern(String q) { diff --git a/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java index 154438ff..991ebf5a 100644 --- a/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java +++ b/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java @@ -1,6 +1,5 @@ package com.Coming.Backend.post.controller; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -10,6 +9,7 @@ import com.Coming.Backend.common.discord.NoOpDiscordNotifier; import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.common.response.PageResponse; import com.Coming.Backend.post.dto.EntityCardResponse; import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.post.service.MentionService; @@ -22,6 +22,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -55,7 +56,8 @@ void should_return_200_with_entity_card_list_when_valid_request_given() throws E // given EntityCardResponse card = new EntityCardResponse( EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", "2025-10-01 · 올림픽공원", null); - given(mentionService.search(eq(EntityType.CONCERT), eq("아이유"), eq(10))).willReturn(List.of(card)); + given(mentionService.search(eq(EntityType.CONCERT), eq("아이유"), eq(0), eq(20))) + .willReturn(PageResponse.from(new PageImpl<>(List.of(card)))); // when & then mockMvc.perform(get("/api/mentions/search") @@ -63,11 +65,11 @@ void should_return_200_with_entity_card_list_when_valid_request_given() throws E .param("q", "아이유") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].type").value("CONCERT")) - .andExpect(jsonPath("$[0].id").value(CONCERT_ID)) - .andExpect(jsonPath("$[0].title").value("아이유 콘서트")); + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].type").value("CONCERT")) + .andExpect(jsonPath("$.content[0].id").value(CONCERT_ID)) + .andExpect(jsonPath("$.content[0].title").value("아이유 콘서트")); } @Test @@ -96,8 +98,8 @@ void should_return_400_when_type_is_invalid_value() throws Exception { @Test void should_return_200_with_empty_array_when_no_search_result_found() throws Exception { // given - given(mentionService.search(eq(EntityType.ARTIST), eq("없는아티스트"), any(Integer.class))) - .willReturn(List.of()); + given(mentionService.search(eq(EntityType.ARTIST), eq("없는아티스트"), eq(0), eq(20))) + .willReturn(PageResponse.from(new PageImpl<>(List.of()))); // when & then mockMvc.perform(get("/api/mentions/search") @@ -105,7 +107,7 @@ void should_return_200_with_empty_array_when_no_search_result_found() throws Exc .param("q", "없는아티스트") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$.length()").value(0)); + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(0)); } } diff --git a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java index b31e85a2..96ac89a4 100644 --- a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java @@ -8,6 +8,7 @@ import com.Coming.Backend.artist.entity.Artist; import com.Coming.Backend.artist.repository.ArtistRepository; +import com.Coming.Backend.common.response.PageResponse; import com.Coming.Backend.concert.entity.Concert; import com.Coming.Backend.concert.repository.ConcertRepository; import com.Coming.Backend.post.dto.EntityCardResponse; @@ -20,12 +21,15 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; @ExtendWith(MockitoExtension.class) class MentionServiceTest { @@ -45,7 +49,8 @@ class MentionServiceTest { @Mock private EntityLookupService entityLookupService; - private static final int LIMIT = 10; + private static final int PAGE = 0; + private static final int SIZE = 10; private static final Long CONCERT_ID = 1L; private static final Long ARTIST_ID = 10L; private static final Long RELEASE_ID = 100L; @@ -57,16 +62,21 @@ void should_return_concert_cards_when_type_is_CONCERT() { String q = "아이유"; Concert concert = Concert.builder().id(CONCERT_ID).title("아이유 콘서트").build(); EntityCardResponse card = new EntityCardResponse(EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", null, null); + Page concertPage = new PageImpl<>(List.of(concert), PageRequest.of(PAGE, SIZE), 1); given(concertRepository.searchByTitleForMention(any(), eq("%아이유%"), any(Pageable.class))) - .willReturn(List.of(concert)); + .willReturn(concertPage); given(entityLookupService.toCard(concert)).willReturn(card); // when - List result = mentionService.search(EntityType.CONCERT, q, LIMIT); + PageResponse result = mentionService.search(EntityType.CONCERT, q, PAGE, SIZE); // then verify(concertRepository).searchByTitleForMention(any(), eq("%아이유%"), any(Pageable.class)); - assertThat(result).containsExactly(card); + assertThat(result.content()).containsExactly(card); + assertThat(result.page()).isEqualTo(PAGE); + assertThat(result.size()).isEqualTo(SIZE); + assertThat(result.totalElements()).isEqualTo(1L); + assertThat(result.totalPages()).isEqualTo(1); } @Test @@ -80,11 +90,11 @@ void should_call_artist_repository_with_raw_query_when_type_is_ARTIST() { given(entityLookupService.toCard(artist)).willReturn(card); // when - List result = mentionService.search(EntityType.ARTIST, q, LIMIT); + PageResponse result = mentionService.search(EntityType.ARTIST, q, PAGE, SIZE); // then verify(artistRepository).findByNameOrAliasContainingIgnoreCase(eq("아이유"), any(Pageable.class)); - assertThat(result).containsExactly(card); + assertThat(result.content()).containsExactly(card); } @Test @@ -101,36 +111,69 @@ void should_map_artist_names_by_release_artist_id_when_type_is_RELEASE() { given(entityLookupService.toCard(release, "IU")).willReturn(card); // when - List result = mentionService.search(EntityType.RELEASE, q, LIMIT); + PageResponse result = mentionService.search(EntityType.RELEASE, q, PAGE, SIZE); // then verify(artistRepository).findAllById(eq(Set.of(RELEASE_ARTIST_ID))); - assertThat(result).containsExactly(card); + assertThat(result.content()).containsExactly(card); } @Test void should_return_empty_list_when_no_search_result_found() { // given given(concertRepository.searchByTitleForMention(any(), eq("%없는공연%"), any(Pageable.class))) - .willReturn(List.of()); + .willReturn(new PageImpl<>(List.of())); // when - List result = mentionService.search(EntityType.CONCERT, "없는공연", LIMIT); + PageResponse result = mentionService.search(EntityType.CONCERT, "없는공연", PAGE, SIZE); // then - assertThat(result).isEmpty(); + assertThat(result.content()).isEmpty(); } @Test void should_escape_like_wildcards_when_query_contains_percent_and_underscore() { // given given(concertRepository.searchByTitleForMention(any(), eq("%50\\%\\_off%"), any(Pageable.class))) - .willReturn(List.of()); + .willReturn(new PageImpl<>(List.of())); // when - mentionService.search(EntityType.CONCERT, "50%_off", LIMIT); + mentionService.search(EntityType.CONCERT, "50%_off", PAGE, SIZE); // then verify(concertRepository).searchByTitleForMention(any(), eq("%50\\%\\_off%"), any(Pageable.class)); } + + @Test + void should_build_pageable_with_requested_page_number_when_page_is_not_zero() { + // given + int requestedPage = 1; + int requestedSize = 5; + given(concertRepository.searchByTitleForMention(any(), any(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + // when + mentionService.search(EntityType.CONCERT, "아이유", requestedPage, requestedSize); + + // then + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(concertRepository).searchByTitleForMention(any(), any(), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getPageNumber()).isEqualTo(requestedPage); + assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(requestedSize); + } + + @Test + void should_request_id_ascending_sort_when_type_is_ARTIST() { + // given + given(artistRepository.findByNameOrAliasContainingIgnoreCase(any(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + // when + mentionService.search(EntityType.ARTIST, "아이유", PAGE, SIZE); + + // then + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(artistRepository).findByNameOrAliasContainingIgnoreCase(any(), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getSort()).isEqualTo(Sort.by(Sort.Direction.ASC, "id")); + } } From 45e65135a3162e124c322b0951aa035bdc036a3c Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 17:06:18 +0900 Subject: [PATCH 3/9] =?UTF-8?q?[feat]=20EntityCardResponse=EC=97=90=20rele?= =?UTF-8?q?aseGroupId=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 트랙 멘션 도입을 위한 준비 작업. 트랙이 속한 앨범 id를 응답에 실어 FE가 앨범 상세 페이지로 링크를 구성할 수 있게 한다. TRACK 외 타입은 당분간 null을 반환한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../com/Coming/Backend/post/dto/EntityCardResponse.java | 3 ++- .../Coming/Backend/post/service/EntityLookupService.java | 6 +++--- .../Backend/post/controller/MentionControllerTest.java | 2 +- .../com/Coming/Backend/post/service/MentionServiceTest.java | 6 +++--- .../com/Coming/Backend/post/service/PostServiceTest.java | 6 +++--- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java index 11e5a0b4..8f743fd5 100644 --- a/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java +++ b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java @@ -7,6 +7,7 @@ public record EntityCardResponse( Long id, String title, String subtitle, - String thumbnailUrl + String thumbnailUrl, + Long releaseGroupId ) { } diff --git a/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java index b65d8504..08c36bc6 100644 --- a/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java +++ b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java @@ -61,15 +61,15 @@ private Set idsOf(Collection keys, EntityType type) { EntityCardResponse toCard(Concert concert) { String subtitle = concert.getStartDate() + " · " + concert.getVenueName(); - return new EntityCardResponse(EntityType.CONCERT, concert.getId(), concert.getTitle(), subtitle, concert.getPosterUrl()); + return new EntityCardResponse(EntityType.CONCERT, concert.getId(), concert.getTitle(), subtitle, concert.getPosterUrl(), null); } EntityCardResponse toCard(Artist artist) { - return new EntityCardResponse(EntityType.ARTIST, artist.getId(), artist.getName(), null, artist.getImageUrl()); + return new EntityCardResponse(EntityType.ARTIST, artist.getId(), artist.getName(), null, artist.getImageUrl(), null); } EntityCardResponse toCard(ReleaseGroup release, String artistName) { - return new EntityCardResponse(EntityType.RELEASE, release.getId(), release.getTitle(), artistName, release.getCoverUrl()); + return new EntityCardResponse(EntityType.RELEASE, release.getId(), release.getTitle(), artistName, release.getCoverUrl(), null); } public record EntityKey(EntityType type, Long id) { diff --git a/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java index 991ebf5a..9977e2aa 100644 --- a/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java +++ b/src/test/java/com/Coming/Backend/post/controller/MentionControllerTest.java @@ -55,7 +55,7 @@ void setUp() { void should_return_200_with_entity_card_list_when_valid_request_given() throws Exception { // given EntityCardResponse card = new EntityCardResponse( - EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", "2025-10-01 · 올림픽공원", null); + EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", "2025-10-01 · 올림픽공원", null, null); given(mentionService.search(eq(EntityType.CONCERT), eq("아이유"), eq(0), eq(20))) .willReturn(PageResponse.from(new PageImpl<>(List.of(card)))); diff --git a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java index 96ac89a4..ce77aaba 100644 --- a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java @@ -61,7 +61,7 @@ void should_return_concert_cards_when_type_is_CONCERT() { // given String q = "아이유"; Concert concert = Concert.builder().id(CONCERT_ID).title("아이유 콘서트").build(); - EntityCardResponse card = new EntityCardResponse(EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", null, null); + EntityCardResponse card = new EntityCardResponse(EntityType.CONCERT, CONCERT_ID, "아이유 콘서트", null, null, null); Page concertPage = new PageImpl<>(List.of(concert), PageRequest.of(PAGE, SIZE), 1); given(concertRepository.searchByTitleForMention(any(), eq("%아이유%"), any(Pageable.class))) .willReturn(concertPage); @@ -85,7 +85,7 @@ void should_call_artist_repository_with_raw_query_when_type_is_ARTIST() { String q = "아이유"; Artist artist = Artist.builder().id(ARTIST_ID).name("아이유").build(); Page page = new PageImpl<>(List.of(artist)); - EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, ARTIST_ID, "아이유", null, null); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, ARTIST_ID, "아이유", null, null, null); given(artistRepository.findByNameOrAliasContainingIgnoreCase(eq(q), any(Pageable.class))).willReturn(page); given(entityLookupService.toCard(artist)).willReturn(card); @@ -104,7 +104,7 @@ void should_map_artist_names_by_release_artist_id_when_type_is_RELEASE() { ReleaseGroup release = ReleaseGroup.builder().id(RELEASE_ID).artistId(RELEASE_ARTIST_ID).title("LILAC").build(); Artist releaseArtist = Artist.builder().id(RELEASE_ARTIST_ID).name("IU").build(); Page page = new PageImpl<>(List.of(release)); - EntityCardResponse card = new EntityCardResponse(EntityType.RELEASE, RELEASE_ID, "LILAC", "IU", null); + EntityCardResponse card = new EntityCardResponse(EntityType.RELEASE, RELEASE_ID, "LILAC", "IU", null, null); given(releaseGroupRepository.searchReleases(eq(null), eq(null), eq(null), eq("%lilac%"), any(Pageable.class))) .willReturn(page); given(artistRepository.findAllById(eq(Set.of(RELEASE_ARTIST_ID)))).willReturn(List.of(releaseArtist)); diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 50c0aec9..2bd4e406 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -407,7 +407,7 @@ void should_map_entity_tags_using_entity_lookup_service_when_post_found() { .entityType(EntityType.ARTIST) .entityId(1L) .build(); - EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg"); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg", null); EntityLookupService.EntityKey key = new EntityLookupService.EntityKey(EntityType.ARTIST, 1L); given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); @@ -559,7 +559,7 @@ void should_return_trending_tags_when_entity_tags_found() { Pageable pageable = PageRequest.of(0, 10); EntityTagCount count = new EntityTagCount(EntityType.ARTIST, 1L, 3L); EntityLookupService.EntityKey key = new EntityLookupService.EntityKey(EntityType.ARTIST, 1L); - EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg"); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg", null); given(postEntityTagRepository.findTrendingEntityTags(any(LocalDateTime.class), eq(pageable))).willReturn(List.of(count)); given(entityLookupService.findCards(List.of(key))).willReturn(Map.of(key, card)); @@ -596,7 +596,7 @@ void should_filter_out_trending_tag_when_entity_reference_is_deleted() { EntityTagCount deletedCount = new EntityTagCount(EntityType.CONCERT, 2L, 2L); EntityLookupService.EntityKey aliveKey = new EntityLookupService.EntityKey(EntityType.ARTIST, 1L); EntityLookupService.EntityKey deletedKey = new EntityLookupService.EntityKey(EntityType.CONCERT, 2L); - EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg"); + EntityCardResponse card = new EntityCardResponse(EntityType.ARTIST, 1L, "IU", null, "https://image.example.com/iu.jpg", null); given(postEntityTagRepository.findTrendingEntityTags(any(LocalDateTime.class), eq(pageable))) .willReturn(List.of(aliveCount, deletedCount)); given(entityLookupService.findCards(List.of(aliveKey, deletedKey))).willReturn(Map.of(aliveKey, card)); From 2be4886ab9c83ae4de8c81fee3537d02910f97d8 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 17:09:22 +0900 Subject: [PATCH 4/9] =?UTF-8?q?[feat]=20TrackRepository=EC=97=90=20?= =?UTF-8?q?=EB=A9=98=EC=85=98=20=EA=B2=80=EC=83=89=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConcertRepository.searchByTitleForMention과 동일한 패턴으로 트랙명 부분 일치 검색을 추가한다. 기존 LIKE 쿼리들과 달리 ESCAPE '\' 절을 명시해 서비스 레이어의 와일드카드 이스케이프가 실제로 동작하도록 했다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../release/repository/TrackRepository.java | 8 ++ .../repository/TrackRepositoryTest.java | 89 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/test/java/com/Coming/Backend/release/repository/TrackRepositoryTest.java diff --git a/src/main/java/com/Coming/Backend/release/repository/TrackRepository.java b/src/main/java/com/Coming/Backend/release/repository/TrackRepository.java index 9883bf23..004609fe 100644 --- a/src/main/java/com/Coming/Backend/release/repository/TrackRepository.java +++ b/src/main/java/com/Coming/Backend/release/repository/TrackRepository.java @@ -1,7 +1,11 @@ package com.Coming.Backend.release.repository; import com.Coming.Backend.release.entity.Track; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Collection; import java.util.List; @@ -11,4 +15,8 @@ public interface TrackRepository extends JpaRepository { List findByReleaseGroupIdOrderByPosition(Long releaseGroupId); List findByReleaseGroupIdInOrderByPosition(Collection releaseGroupIds); + + @Query(value = "SELECT t FROM Track t WHERE LOWER(t.title) LIKE :q ESCAPE '\\' ORDER BY t.id ASC", + countQuery = "SELECT COUNT(t) FROM Track t WHERE LOWER(t.title) LIKE :q ESCAPE '\\'") + Page searchByTitleForMention(@Param("q") String q, Pageable pageable); } diff --git a/src/test/java/com/Coming/Backend/release/repository/TrackRepositoryTest.java b/src/test/java/com/Coming/Backend/release/repository/TrackRepositoryTest.java new file mode 100644 index 00000000..f46dd80d --- /dev/null +++ b/src/test/java/com/Coming/Backend/release/repository/TrackRepositoryTest.java @@ -0,0 +1,89 @@ +package com.Coming.Backend.release.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.release.entity.Track; + +import java.util.Locale; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class TrackRepositoryTest { + + @Autowired + private TrackRepository trackRepository; + + private static final Long RELEASE_GROUP_ID = 1L; + + private String likeQuery(String q) { + return "%" + q.toLowerCase(Locale.ROOT) + "%"; + } + + private Track buildTrack(String title) { + return Track.builder() + .releaseGroupId(RELEASE_GROUP_ID) + .title(title) + .position(1) + .build(); + } + + @Test + void should_return_track_when_title_partially_matches_search_query_ignoring_case() { + // given + Track track = trackRepository.save(buildTrack("Dynamite")); + + // when + Page result = trackRepository.searchByTitleForMention(likeQuery("DYNA"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Track::getId).contains(track.getId()); + } + + @Test + void should_exclude_track_when_title_does_not_match_search_query() { + // given + Track matching = trackRepository.save(buildTrack("Dynamite")); + Track notMatching = trackRepository.save(buildTrack("Butter")); + + // when + Page result = trackRepository.searchByTitleForMention(likeQuery("Dyna"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Track::getId) + .contains(matching.getId()) + .doesNotContain(notMatching.getId()); + } + + @Test + void should_return_empty_page_when_no_track_matches_search_query() { + // given + trackRepository.save(buildTrack("Dynamite")); + + // when + Page result = trackRepository.searchByTitleForMention(likeQuery("존재하지않는검색어"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).isEmpty(); + } + + @Test + void should_match_wildcard_characters_literally_when_escaped_query_given() { + // given + Track track = trackRepository.save(buildTrack("100% Ready")); + trackRepository.save(buildTrack("100 Ready")); + String escapedQuery = "%" + "100\\% ready".toLowerCase(Locale.ROOT) + "%"; + + // when + Page result = trackRepository.searchByTitleForMention(escapedQuery, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Track::getId).containsExactly(track.getId()); + } +} From e1bb5ce85a18ffbf76c307900ad4c8e1516d646c Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 17:20:59 +0900 Subject: [PATCH 5/9] =?UTF-8?q?[feat]=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EB=A9=98=EC=85=98=EC=97=90=20TRACK=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앨범(RELEASE) 단위로만 지원되던 음악 멘션에 트랙 단위를 추가한다. MentionService.searchTracks()가 트랙명으로 검색하고, EntityLookupService.toTrackCardsById()가 트랙 → 앨범 → 아티스트를 배치 조회해 카드(제목=트랙명, 부제=아티스트·앨범명, releaseGroupId= FE 링크 구성용)로 변환한다. 앨범 참조가 끊긴 트랙(FK 제약 없음)은 제목만 채운 카드로 안전하게 대체한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/post/dto/EntityCardResponse.java | 1 + .../Backend/post/entity/EntityType.java | 2 +- .../post/service/EntityLookupService.java | 35 ++++++++++++ .../Backend/post/service/MentionService.java | 10 ++++ .../post/service/EntityLookupServiceTest.java | 54 +++++++++++++++++++ .../post/service/MentionServiceTest.java | 25 +++++++++ 6 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java index 8f743fd5..dbee09f9 100644 --- a/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java +++ b/src/main/java/com/Coming/Backend/post/dto/EntityCardResponse.java @@ -8,6 +8,7 @@ public record EntityCardResponse( String title, String subtitle, String thumbnailUrl, + // TRACK 타입에서만 채워진다 (트랙이 속한 앨범 id). 그 외 타입은 항상 null. Long releaseGroupId ) { } diff --git a/src/main/java/com/Coming/Backend/post/entity/EntityType.java b/src/main/java/com/Coming/Backend/post/entity/EntityType.java index e52d9467..a6f1bac4 100644 --- a/src/main/java/com/Coming/Backend/post/entity/EntityType.java +++ b/src/main/java/com/Coming/Backend/post/entity/EntityType.java @@ -1,5 +1,5 @@ package com.Coming.Backend.post.entity; public enum EntityType { - CONCERT, ARTIST, RELEASE + CONCERT, ARTIST, RELEASE, TRACK } diff --git a/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java index 08c36bc6..22c85758 100644 --- a/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java +++ b/src/main/java/com/Coming/Backend/post/service/EntityLookupService.java @@ -7,7 +7,9 @@ import com.Coming.Backend.post.dto.EntityCardResponse; import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.entity.Track; import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import com.Coming.Backend.release.repository.TrackRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +19,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; @Service @@ -27,6 +30,7 @@ public class EntityLookupService { private final ConcertRepository concertRepository; private final ArtistRepository artistRepository; private final ReleaseGroupRepository releaseGroupRepository; + private final TrackRepository trackRepository; /** * entityType·entityId 키 목록에 대응하는 엔티티 카드 정보를 일괄 조회한다. @@ -49,9 +53,32 @@ public Map findCards(Collection keys) result.put(new EntityKey(EntityType.RELEASE, release.getId()), toCard(release, artistNames.get(release.getArtistId())))); + List tracks = trackRepository.findAllById(idsOf(keys, EntityType.TRACK)); + toTrackCardsById(tracks).forEach((trackId, card) -> + result.put(new EntityKey(EntityType.TRACK, trackId), card)); + return result; } + /** + * 트랙 목록이 속한 앨범·아티스트 정보를 배치 조회해 트랙 id별 카드로 변환한다. + * 앨범이 삭제되어 참조가 끊긴 트랙은 제목만 채운 카드를 반환한다. + */ + Map toTrackCardsById(Collection tracks) { + Map releaseGroupsById = releaseGroupRepository.findAllById( + tracks.stream().map(Track::getReleaseGroupId).collect(Collectors.toSet()) + ).stream().collect(Collectors.toMap(ReleaseGroup::getId, Function.identity())); + Map artistNames = artistRepository.findAllById( + releaseGroupsById.values().stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet()) + ).stream().collect(Collectors.toMap(Artist::getId, Artist::getName)); + + return tracks.stream().collect(Collectors.toMap(Track::getId, track -> { + ReleaseGroup releaseGroup = releaseGroupsById.get(track.getReleaseGroupId()); + String artistName = releaseGroup != null ? artistNames.get(releaseGroup.getArtistId()) : null; + return toCard(track, releaseGroup, artistName); + })); + } + private Set idsOf(Collection keys, EntityType type) { return keys.stream() .filter(key -> key.type() == type) @@ -72,6 +99,14 @@ EntityCardResponse toCard(ReleaseGroup release, String artistName) { return new EntityCardResponse(EntityType.RELEASE, release.getId(), release.getTitle(), artistName, release.getCoverUrl(), null); } + EntityCardResponse toCard(Track track, ReleaseGroup releaseGroup, String artistName) { + if (releaseGroup == null) { + return new EntityCardResponse(EntityType.TRACK, track.getId(), track.getTitle(), null, null, null); + } + String subtitle = artistName != null ? artistName + " · " + releaseGroup.getTitle() : releaseGroup.getTitle(); + return new EntityCardResponse(EntityType.TRACK, track.getId(), track.getTitle(), subtitle, releaseGroup.getCoverUrl(), releaseGroup.getId()); + } + public record EntityKey(EntityType type, Long id) { } } diff --git a/src/main/java/com/Coming/Backend/post/service/MentionService.java b/src/main/java/com/Coming/Backend/post/service/MentionService.java index b91e4f24..8055ef42 100644 --- a/src/main/java/com/Coming/Backend/post/service/MentionService.java +++ b/src/main/java/com/Coming/Backend/post/service/MentionService.java @@ -9,7 +9,9 @@ import com.Coming.Backend.post.dto.EntityCardResponse; import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.entity.Track; import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import com.Coming.Backend.release.repository.TrackRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -36,6 +38,7 @@ public class MentionService { private final ConcertRepository concertRepository; private final ArtistRepository artistRepository; private final ReleaseGroupRepository releaseGroupRepository; + private final TrackRepository trackRepository; private final EntityLookupService entityLookupService; /** @@ -47,6 +50,7 @@ public PageResponse search(EntityType type, String q, int pa case CONCERT -> searchConcerts(q, PageRequest.of(page, size)); case ARTIST -> searchArtists(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))); case RELEASE -> searchReleases(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))); + case TRACK -> searchTracks(q, PageRequest.of(page, size)); }; } @@ -72,6 +76,12 @@ private PageResponse searchReleases(String q, Pageable pagea ); } + private PageResponse searchTracks(String q, Pageable pageable) { + Page tracks = trackRepository.searchByTitleForMention(toLikePattern(q), pageable); + Map cardsByTrackId = entityLookupService.toTrackCardsById(tracks.getContent()); + return PageResponse.from(tracks.map(track -> cardsByTrackId.get(track.getId()))); + } + private String toLikePattern(String q) { return "%" + escapeLikeWildcards(q.toLowerCase(Locale.ROOT)) + "%"; } diff --git a/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java b/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java index b5f5fa43..4cd07016 100644 --- a/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/EntityLookupServiceTest.java @@ -12,7 +12,9 @@ import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.post.service.EntityLookupService.EntityKey; import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.entity.Track; import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import com.Coming.Backend.release.repository.TrackRepository; import java.time.LocalDate; import java.util.List; @@ -40,10 +42,14 @@ class EntityLookupServiceTest { @Mock private ReleaseGroupRepository releaseGroupRepository; + @Mock + private TrackRepository trackRepository; + private static final Long CONCERT_ID = 1L; private static final Long ARTIST_ID = 10L; private static final Long RELEASE_ID = 100L; private static final Long RELEASE_ARTIST_ID = 20L; + private static final Long TRACK_ID = 200L; private Concert buildConcert(Long id) { return Concert.builder() @@ -73,6 +79,15 @@ private ReleaseGroup buildReleaseGroup(Long id, Long artistId) { .build(); } + private Track buildTrack(Long id, Long releaseGroupId) { + return Track.builder() + .id(id) + .releaseGroupId(releaseGroupId) + .title("라일락") + .position(1) + .build(); + } + @Test void should_fill_subtitle_with_date_and_venue_and_thumbnail_with_poster_url_when_concert_key_given() { // given @@ -123,6 +138,45 @@ void should_fill_subtitle_with_joined_artist_name_and_thumbnail_with_cover_url_w assertThat(card.thumbnailUrl()).isEqualTo("https://example.com/release-100.jpg"); } + @Test + void should_fill_subtitle_with_artist_and_album_and_thumbnail_with_cover_url_when_track_key_given() { + // given + Track track = buildTrack(TRACK_ID, RELEASE_ID); + ReleaseGroup releaseGroup = buildReleaseGroup(RELEASE_ID, RELEASE_ARTIST_ID); + Artist artist = buildArtist(RELEASE_ARTIST_ID, "IU"); + given(trackRepository.findAllById(any())).willReturn(List.of(track)); + given(releaseGroupRepository.findAllById(any())).willReturn(List.of(releaseGroup)); + given(artistRepository.findAllById(any())).willReturn(List.of(artist)); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.TRACK, TRACK_ID))); + + // then + EntityCardResponse card = result.get(new EntityKey(EntityType.TRACK, TRACK_ID)); + assertThat(card.subtitle()).isEqualTo("IU · LILAC"); + assertThat(card.thumbnailUrl()).isEqualTo("https://example.com/release-100.jpg"); + } + + @Test + void should_fill_only_title_when_track_release_group_not_found() { + // given + Track track = buildTrack(TRACK_ID, RELEASE_ID); + given(trackRepository.findAllById(any())).willReturn(List.of(track)); + given(releaseGroupRepository.findAllById(any())).willReturn(List.of()); + + // when + Map result = + entityLookupService.findCards(List.of(new EntityKey(EntityType.TRACK, TRACK_ID))); + + // then + EntityCardResponse card = result.get(new EntityKey(EntityType.TRACK, TRACK_ID)); + assertThat(card.title()).isEqualTo("라일락"); + assertThat(card.subtitle()).isNull(); + assertThat(card.thumbnailUrl()).isNull(); + assertThat(card.releaseGroupId()).isNull(); + } + @Test void should_fill_all_three_types_when_mixed_keys_given() { // given diff --git a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java index ce77aaba..6d3d6200 100644 --- a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java @@ -14,9 +14,12 @@ import com.Coming.Backend.post.dto.EntityCardResponse; import com.Coming.Backend.post.entity.EntityType; import com.Coming.Backend.release.entity.ReleaseGroup; +import com.Coming.Backend.release.entity.Track; import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import com.Coming.Backend.release.repository.TrackRepository; import java.util.List; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; @@ -46,6 +49,9 @@ class MentionServiceTest { @Mock private ReleaseGroupRepository releaseGroupRepository; + @Mock + private TrackRepository trackRepository; + @Mock private EntityLookupService entityLookupService; @@ -55,6 +61,7 @@ class MentionServiceTest { private static final Long ARTIST_ID = 10L; private static final Long RELEASE_ID = 100L; private static final Long RELEASE_ARTIST_ID = 20L; + private static final Long TRACK_ID = 200L; @Test void should_return_concert_cards_when_type_is_CONCERT() { @@ -118,6 +125,24 @@ void should_map_artist_names_by_release_artist_id_when_type_is_RELEASE() { assertThat(result.content()).containsExactly(card); } + @Test + void should_return_track_cards_when_type_is_TRACK() { + // given + String q = "라일락"; + Track track = Track.builder().id(TRACK_ID).releaseGroupId(RELEASE_ID).title("라일락").position(1).build(); + Page trackPage = new PageImpl<>(List.of(track)); + EntityCardResponse card = new EntityCardResponse(EntityType.TRACK, TRACK_ID, "라일락", "IU · LILAC", null, RELEASE_ID); + given(trackRepository.searchByTitleForMention(eq("%라일락%"), any(Pageable.class))).willReturn(trackPage); + given(entityLookupService.toTrackCardsById(List.of(track))).willReturn(Map.of(TRACK_ID, card)); + + // when + PageResponse result = mentionService.search(EntityType.TRACK, q, PAGE, SIZE); + + // then + verify(trackRepository).searchByTitleForMention(eq("%라일락%"), any(Pageable.class)); + assertThat(result.content()).containsExactly(card); + } + @Test void should_return_empty_list_when_no_search_result_found() { // given From 28520a4714f354bcc5ec12d5fae008521422c2ef Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 17:21:07 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A0=84=EB=AC=B8=EA=B2=80=EC=83=89=EC=97=90=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=99=EB=AA=85=20=EB=A7=A4=EC=B9=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostRepository.searchPosts()가 ARTIST/CONCERT/RELEASE 태그된 게시글만 이름으로 찾을 수 있었다. TRACK 태그 조건을 동일한 형태로 추가해, 트랙이 멘션된 게시글도 트랙명으로 검색되게 한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../post/repository/PostRepository.java | 2 ++ .../post/repository/PostRepositoryTest.java | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java index 3e09e461..d03b4ab3 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -54,6 +54,8 @@ AND t.entityId IN (SELECT a.id FROM Artist a WHERE LOWER(a.name) LIKE :q)) AND t.entityId IN (SELECT c.id FROM Concert c WHERE LOWER(c.title) LIKE :q)) OR (t.entityType = com.Coming.Backend.post.entity.EntityType.RELEASE AND t.entityId IN (SELECT r.id FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q)) + OR (t.entityType = com.Coming.Backend.post.entity.EntityType.TRACK + AND t.entityId IN (SELECT tr.id FROM Track tr WHERE LOWER(tr.title) LIKE :q)) ) ORDER BY p.createdAt DESC """) diff --git a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java index 43e286c1..5e9d5334 100644 --- a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java @@ -8,6 +8,8 @@ import com.Coming.Backend.post.entity.Post; import com.Coming.Backend.post.entity.PostCategory; import com.Coming.Backend.post.entity.PostEntityTag; +import com.Coming.Backend.release.entity.Track; +import com.Coming.Backend.release.repository.TrackRepository; import java.util.Locale; import java.util.UUID; @@ -33,6 +35,9 @@ class PostRepositoryTest { @Autowired private ArtistRepository artistRepository; + @Autowired + private TrackRepository trackRepository; + private static final Long AUTHOR_ID = 1L; private String likeQuery(String q) { @@ -98,6 +103,28 @@ void should_return_post_when_tagged_artist_name_matches_search_query() { assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); } + @Test + void should_return_post_when_tagged_track_title_matches_search_query() { + // given + Track track = trackRepository.save(Track.builder() + .releaseGroupId(1L) + .title("LILAC") + .position(1) + .build()); + Post post = postRepository.save(buildPost("이 곡 좋아요", "요즘 계속 듣는 중")); + postEntityTagRepository.save(PostEntityTag.builder() + .postId(post.getId()) + .entityType(EntityType.TRACK) + .entityId(track.getId()) + .build()); + + // when + Page result = postRepository.searchPosts(likeQuery("LILAC"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); + } + @Test void should_return_empty_page_when_no_post_matches_search_query() { // given From 24a3f0ea07eae7ad2858d5fa40c3ba93a1a777c1 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 17:31:50 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[fix]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20entit?= =?UTF-8?q?yTags=20=EC=9D=91=EB=8B=B5=EC=97=90=20=ED=8A=B8=EB=9E=99=20?= =?UTF-8?q?=EC=95=A8=EB=B2=94=20id=20=EB=88=84=EB=9D=BD=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRACK 멘션 카드(EntityCardResponse)에는 releaseGroupId가 채워지지만, 게시글 상세·목록의 entityTags(PostEntityTagResponse)에는 반영되지 않아 게시글 하단 태그 칩에서 트랙 앨범으로 딥링크할 수 없었다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../post/dto/PostEntityTagResponse.java | 6 +++-- .../Backend/post/service/PostServiceTest.java | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java b/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java index a9bd57c2..bcfdb68c 100644 --- a/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java +++ b/src/main/java/com/Coming/Backend/post/dto/PostEntityTagResponse.java @@ -7,9 +7,11 @@ public record PostEntityTagResponse( Long entityId, String title, String subtitle, - String thumbnailUrl + String thumbnailUrl, + // TRACK 타입에서만 채워진다 (트랙이 속한 앨범 id). 그 외 타입은 항상 null. + Long releaseGroupId ) { public static PostEntityTagResponse of(EntityType entityType, Long entityId, EntityCardResponse card) { - return new PostEntityTagResponse(entityType, entityId, card.title(), card.subtitle(), card.thumbnailUrl()); + return new PostEntityTagResponse(entityType, entityId, card.title(), card.subtitle(), card.thumbnailUrl(), card.releaseGroupId()); } } diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 2bd4e406..18cc419b 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -425,6 +425,33 @@ void should_map_entity_tags_using_entity_lookup_service_when_post_found() { assertThat(response.entityTags().get(0).title()).isEqualTo("IU"); } + @Test + void should_include_release_group_id_when_entity_tag_is_track() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.REVIEW, "제목", 0L); + PostEntityTag tag = PostEntityTag.builder() + .postId(POST_ID) + .entityType(EntityType.TRACK) + .entityId(200L) + .build(); + EntityCardResponse card = new EntityCardResponse(EntityType.TRACK, 200L, "라일락", "IU · LILAC", null, 100L); + EntityLookupService.EntityKey key = new EntityLookupService.EntityKey(EntityType.TRACK, 200L); + + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + given(postEntityTagRepository.findByPostId(POST_ID)).willReturn(List.of(tag)); + given(entityLookupService.findCards(List.of(key))).willReturn(Map.of(key, card)); + given(userRepository.findById(AUTHOR_ID)).willReturn(Optional.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PostDetailResponse response = postService.getDetail(POST_ID, null); + + // then + assertThat(response.entityTags()).hasSize(1); + assertThat(response.entityTags().get(0).entityType()).isEqualTo(EntityType.TRACK); + assertThat(response.entityTags().get(0).title()).isEqualTo("라일락"); + assertThat(response.entityTags().get(0).releaseGroupId()).isEqualTo(100L); + } + // ------------------------------------------------------------------------- // getList // ------------------------------------------------------------------------- From 5e4985b8a61453c097d62554fe0b3fd441448daf Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Tue, 15 Sep 2026 17:44:09 +0900 Subject: [PATCH 8/9] =?UTF-8?q?[feat]=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EA=B2=8C=EC=8B=9C=EA=B8=80=EC=97=90=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=99=20=ED=83=9C=EA=B7=B8=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 트랙 앵커가 앨범 상세 페이지로 귀결되는 구조라, RELEASE 백링크 조회 시 해당 릴리즈에 속한 트랙이 태그된 게시글도 함께 노출되도록 findByEntityTag 확장 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../post/repository/PostRepository.java | 16 ++++++- .../post/repository/PostRepositoryTest.java | 45 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java index d03b4ab3..2a6b258a 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -40,7 +40,21 @@ public interface PostRepository extends JpaRepository { @Query("UPDATE Post p SET p.commentCount = p.commentCount + 1 WHERE p.id = :id") void incrementCommentCount(@Param("id") Long id); - @Query("SELECT p FROM Post p WHERE p.id IN (SELECT t.postId FROM PostEntityTag t WHERE t.entityType = :entityType AND t.entityId = :entityId)") + /** + * RELEASE 조회 시, 해당 릴리즈에 속한 트랙(Track.releaseGroupId)이 태그된 게시글도 함께 포함한다. + * 트랙 앵커가 앨범 상세 페이지로 귀결되는 구조(`/releases/{releaseGroupId}#track-{id}`)이므로, + * 트랙을 언급한 글도 해당 앨범의 관련 게시글로 노출되어야 사용자에게 자연스럽다. + */ + @Query(""" + SELECT p FROM Post p + WHERE p.id IN ( + SELECT t.postId FROM PostEntityTag t + WHERE (t.entityType = :entityType AND t.entityId = :entityId) + OR (:entityType = com.Coming.Backend.post.entity.EntityType.RELEASE + AND t.entityType = com.Coming.Backend.post.entity.EntityType.TRACK + AND t.entityId IN (SELECT tr.id FROM Track tr WHERE tr.releaseGroupId = :entityId)) + ) + """) Page findByEntityTag(@Param("entityType") EntityType entityType, @Param("entityId") Long entityId, Pageable pageable); @Query(""" diff --git a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java index 5e9d5334..26ec90d3 100644 --- a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java @@ -137,6 +137,51 @@ void should_return_empty_page_when_no_post_matches_search_query() { assertThat(result.getContent()).isEmpty(); } + @Test + void should_return_post_when_tagged_track_belongs_to_release() { + // given + Long releaseGroupId = 1L; + Track track = trackRepository.save(Track.builder() + .releaseGroupId(releaseGroupId) + .title("LILAC") + .position(1) + .build()); + Post post = postRepository.save(buildPost("이 앨범의 이 곡이 좋아요", "명곡이다")); + postEntityTagRepository.save(PostEntityTag.builder() + .postId(post.getId()) + .entityType(EntityType.TRACK) + .entityId(track.getId()) + .build()); + + // when + Page result = postRepository.findByEntityTag(EntityType.RELEASE, releaseGroupId, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(post.getId()); + } + + @Test + void should_not_return_post_when_tagged_track_belongs_to_other_release() { + // given + Track track = trackRepository.save(Track.builder() + .releaseGroupId(2L) + .title("다른 앨범 트랙") + .position(1) + .build()); + Post post = postRepository.save(buildPost("다른 앨범 곡 얘기", "내용")); + postEntityTagRepository.save(PostEntityTag.builder() + .postId(post.getId()) + .entityType(EntityType.TRACK) + .entityId(track.getId()) + .build()); + + // when + Page result = postRepository.findByEntityTag(EntityType.RELEASE, 1L, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).doesNotContain(post.getId()); + } + @Test void should_return_current_recommend_count_when_finding_recommend_count_by_id() { // given From 6a71b2fb248f9329761c27418dc4ba8b086b55e8 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 16:58:16 +0900 Subject: [PATCH 9/9] =?UTF-8?q?[fix]=20=EC=9D=8C=EC=95=85=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=A9=98=EC=85=98=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=8A=B8=EB=9E=99=EB=AA=85=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRACK이 별도 멘션 타입으로 분리된 이후에도 RELEASE 멘션 검색이 내부 트랙명까지 매칭해, 검색어와 무관해 보이는 앨범이 노출되는 문제가 있었다. 멘션 전용 쿼리(searchByTitleForMention)를 추가해 릴리즈 제목만 매칭하도록 분리하고, 기존 searchReleases(트랙/아티스트명 포함 매칭)는 일반 릴리즈 목록 API에서 그대로 사용한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/post/service/MentionService.java | 4 +- .../repository/ReleaseGroupRepository.java | 4 + .../post/service/MentionServiceTest.java | 2 +- .../ReleaseGroupRepositoryTest.java | 88 +++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/Coming/Backend/release/repository/ReleaseGroupRepositoryTest.java diff --git a/src/main/java/com/Coming/Backend/post/service/MentionService.java b/src/main/java/com/Coming/Backend/post/service/MentionService.java index 8055ef42..b97ddcc5 100644 --- a/src/main/java/com/Coming/Backend/post/service/MentionService.java +++ b/src/main/java/com/Coming/Backend/post/service/MentionService.java @@ -49,7 +49,7 @@ public PageResponse search(EntityType type, String q, int pa return switch (type) { case CONCERT -> searchConcerts(q, PageRequest.of(page, size)); case ARTIST -> searchArtists(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))); - case RELEASE -> searchReleases(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"))); + case RELEASE -> searchReleases(q, PageRequest.of(page, size)); case TRACK -> searchTracks(q, PageRequest.of(page, size)); }; } @@ -67,7 +67,7 @@ private PageResponse searchArtists(String q, Pageable pageab private PageResponse searchReleases(String q, Pageable pageable) { Page releases = releaseGroupRepository - .searchReleases(null, null, null, toLikePattern(q), pageable); + .searchByTitleForMention(toLikePattern(q), pageable); Map artistNames = artistRepository.findAllById( releases.getContent().stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet()) ).stream().collect(Collectors.toMap(Artist::getId, Artist::getName)); diff --git a/src/main/java/com/Coming/Backend/release/repository/ReleaseGroupRepository.java b/src/main/java/com/Coming/Backend/release/repository/ReleaseGroupRepository.java index 5cb8a564..0c7b0572 100644 --- a/src/main/java/com/Coming/Backend/release/repository/ReleaseGroupRepository.java +++ b/src/main/java/com/Coming/Backend/release/repository/ReleaseGroupRepository.java @@ -65,4 +65,8 @@ Page searchReleases(@Param("artistId") Long artistId, @Param("type") String type, @Param("q") String q, Pageable pageable); + + @Query(value = "SELECT r FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q ESCAPE '\\' ORDER BY r.id ASC", + countQuery = "SELECT COUNT(r) FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q ESCAPE '\\'") + Page searchByTitleForMention(@Param("q") String q, Pageable pageable); } diff --git a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java index 6d3d6200..7b0b8870 100644 --- a/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/MentionServiceTest.java @@ -112,7 +112,7 @@ void should_map_artist_names_by_release_artist_id_when_type_is_RELEASE() { Artist releaseArtist = Artist.builder().id(RELEASE_ARTIST_ID).name("IU").build(); Page page = new PageImpl<>(List.of(release)); EntityCardResponse card = new EntityCardResponse(EntityType.RELEASE, RELEASE_ID, "LILAC", "IU", null, null); - given(releaseGroupRepository.searchReleases(eq(null), eq(null), eq(null), eq("%lilac%"), any(Pageable.class))) + given(releaseGroupRepository.searchByTitleForMention(eq("%lilac%"), any(Pageable.class))) .willReturn(page); given(artistRepository.findAllById(eq(Set.of(RELEASE_ARTIST_ID)))).willReturn(List.of(releaseArtist)); given(entityLookupService.toCard(release, "IU")).willReturn(card); diff --git a/src/test/java/com/Coming/Backend/release/repository/ReleaseGroupRepositoryTest.java b/src/test/java/com/Coming/Backend/release/repository/ReleaseGroupRepositoryTest.java new file mode 100644 index 00000000..f31fabe5 --- /dev/null +++ b/src/test/java/com/Coming/Backend/release/repository/ReleaseGroupRepositoryTest.java @@ -0,0 +1,88 @@ +package com.Coming.Backend.release.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.release.entity.ReleaseGroup; + +import java.util.Locale; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ReleaseGroupRepositoryTest { + + @Autowired + private ReleaseGroupRepository releaseGroupRepository; + + private static final Long ARTIST_ID = 1L; + + private String likeQuery(String q) { + return "%" + q.toLowerCase(Locale.ROOT) + "%"; + } + + private ReleaseGroup buildReleaseGroup(String title) { + return ReleaseGroup.builder() + .artistId(ARTIST_ID) + .title(title) + .build(); + } + + @Test + void should_return_release_when_title_partially_matches_search_query_ignoring_case() { + // given + ReleaseGroup release = releaseGroupRepository.save(buildReleaseGroup("LILAC")); + + // when + Page result = releaseGroupRepository.searchByTitleForMention(likeQuery("lil"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(ReleaseGroup::getId).contains(release.getId()); + } + + @Test + void should_exclude_release_when_title_does_not_match_search_query() { + // given + ReleaseGroup matching = releaseGroupRepository.save(buildReleaseGroup("LILAC")); + ReleaseGroup notMatching = releaseGroupRepository.save(buildReleaseGroup("Palette")); + + // when + Page result = releaseGroupRepository.searchByTitleForMention(likeQuery("lil"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(ReleaseGroup::getId) + .contains(matching.getId()) + .doesNotContain(notMatching.getId()); + } + + @Test + void should_return_empty_page_when_no_release_matches_search_query() { + // given + releaseGroupRepository.save(buildReleaseGroup("LILAC")); + + // when + Page result = releaseGroupRepository.searchByTitleForMention(likeQuery("존재하지않는검색어"), PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).isEmpty(); + } + + @Test + void should_match_wildcard_characters_literally_when_escaped_query_given() { + // given + ReleaseGroup release = releaseGroupRepository.save(buildReleaseGroup("100% Ready")); + releaseGroupRepository.save(buildReleaseGroup("100 Ready")); + String escapedQuery = "%" + "100\\% ready".toLowerCase(Locale.ROOT) + "%"; + + // when + Page result = releaseGroupRepository.searchByTitleForMention(escapedQuery, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(ReleaseGroup::getId).containsExactly(release.getId()); + } +}