diff --git a/musical/src/main/java/com/truve/platform/musical/show/controller/SearchController.java b/musical/src/main/java/com/truve/platform/musical/show/controller/SearchController.java new file mode 100644 index 00000000..001edb1e --- /dev/null +++ b/musical/src/main/java/com/truve/platform/musical/show/controller/SearchController.java @@ -0,0 +1,42 @@ +package com.truve.platform.musical.show.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.truve.platform.common.response.ApiResult; +import com.truve.platform.musical.show.dto.SearchResponse; +import com.truve.platform.musical.show.service.SearchService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/musical") +public class SearchController { + + private final SearchService searchService; + + @Operation( + summary = "통합 검색", + description = "공연명/배우명으로 통합 검색 결과를 조회합니다." + ) + @GetMapping("/search") + public ApiResult search( + @Parameter(description = "검색어(공백 입력 시 빈 결과 반환)") + @RequestParam(name = "keyword", required = false) String keyword, + @Parameter(description = "아티스트 검색 시작 위치") + @RequestParam(name = "artistOffset", defaultValue = "0") int artistOffset, + @Parameter(description = "아티스트 한 페이지 개수") + @RequestParam(name = "artistLimit", defaultValue = "20") int artistLimit, + @Parameter(description = "공연 검색 시작 위치") + @RequestParam(name = "showOffset", defaultValue = "0") int showOffset, + @Parameter(description = "공연 한 페이지 개수") + @RequestParam(name = "showLimit", defaultValue = "20") int showLimit + ) { + return ApiResult.ok(searchService.search(keyword, artistOffset, artistLimit, showOffset, showLimit)); + } +} \ No newline at end of file diff --git a/musical/src/main/java/com/truve/platform/musical/show/dto/SearchResponse.java b/musical/src/main/java/com/truve/platform/musical/show/dto/SearchResponse.java new file mode 100644 index 00000000..64bf1719 --- /dev/null +++ b/musical/src/main/java/com/truve/platform/musical/show/dto/SearchResponse.java @@ -0,0 +1,106 @@ +package com.truve.platform.musical.show.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +public class SearchResponse { + + @Getter + @AllArgsConstructor + @Builder + public static class SearchResult { + private String keyword; + private int artistCount; + private int showCount; + private boolean hasMoreArtists; + private boolean hasMoreShows; + private List artists; + private List shows; + + public static SearchResult of( + String keyword, + List artists, + List shows, + int totalArtistCount, + int totalShowCount, + boolean hasMoreArtists, + boolean hasMoreShows + ) { + return SearchResult.builder() + .keyword(keyword) + .artistCount(totalArtistCount) + .showCount(totalShowCount) + .hasMoreArtists(hasMoreArtists) + .hasMoreShows(hasMoreShows) + .artists(artists) + .shows(shows) + .build(); + } + + public static SearchResult empty(String keyword) { + return SearchResult.builder() + .keyword(keyword) + .artistCount(0) + .showCount(0) + .hasMoreArtists(false) + .hasMoreShows(false) + .artists(List.of()) + .shows(List.of()) + .build(); + } + } + + @Getter + @AllArgsConstructor + @Builder + public static class ArtistSummary { + private Long artistId; + private String artistName; + private String profileImageUrl; + private String appearanceInfo; + + public static ArtistSummary of( + Long artistId, + String artistName, + String profileImageUrl, + String appearanceInfo + ) { + return ArtistSummary.builder() + .artistId(artistId) + .artistName(artistName) + .profileImageUrl(profileImageUrl) + .appearanceInfo(appearanceInfo) + .build(); + } + } + + @Getter + @AllArgsConstructor + @Builder + public static class ShowSummary { + private Long showId; + private String posterUrl; + private String title; + private String venueName; + private String date; + + public static ShowSummary of( + Long showId, + String posterUrl, + String title, + String venueName, + String date + ) { + return ShowSummary.builder() + .showId(showId) + .posterUrl(posterUrl) + .title(title) + .venueName(venueName) + .date(date) + .build(); + } + } +} \ No newline at end of file diff --git a/musical/src/main/java/com/truve/platform/musical/show/repository/ArtistRepository.java b/musical/src/main/java/com/truve/platform/musical/show/repository/ArtistRepository.java index 565b1a6d..8114d428 100644 --- a/musical/src/main/java/com/truve/platform/musical/show/repository/ArtistRepository.java +++ b/musical/src/main/java/com/truve/platform/musical/show/repository/ArtistRepository.java @@ -1,8 +1,37 @@ package com.truve.platform.musical.show.repository; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.truve.platform.musical.show.domain.entity.Artist; public interface ArtistRepository extends JpaRepository { -} + interface ArtistSearchProjection { + Long getArtistId(); + + String getArtistName(); + + String getProfileImg(); + } + + @Query(""" + select + a.id as artistId, + a.name as artistName, + a.profileImg as profileImg + from Artist a + where a.name like concat('%', :keyword, '%') + order by + case + when lower(a.name) = lower(:keyword) then 0 + else 1 + end asc, + a.name asc, + a.id asc + """) + List searchArtists(@Param("keyword") String keyword); + +} \ No newline at end of file diff --git a/musical/src/main/java/com/truve/platform/musical/show/repository/ShowCastingRepository.java b/musical/src/main/java/com/truve/platform/musical/show/repository/ShowCastingRepository.java index fa20a93f..65671a29 100644 --- a/musical/src/main/java/com/truve/platform/musical/show/repository/ShowCastingRepository.java +++ b/musical/src/main/java/com/truve/platform/musical/show/repository/ShowCastingRepository.java @@ -1,5 +1,6 @@ package com.truve.platform.musical.show.repository; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,6 +10,21 @@ import com.truve.platform.musical.show.domain.entity.ShowCasting; public interface ShowCastingRepository extends JpaRepository { + interface ArtistAppearanceProjection { + Long getArtistId(); + + Long getShowId(); + + String getShowTitle(); + + String getPosterImg(); + + String getVenueName(); + + LocalDateTime getStartTime(); + + LocalDateTime getEndTime(); + } @Query(""" select c @@ -21,4 +37,27 @@ public interface ShowCastingRepository extends JpaRepository c.id asc """) List findAllByShowId(@Param("showId") Long showId); -} + + @Query(""" + select distinct + sc.artist.id as artistId, + sc.show.id as showId, + sc.show.title as showTitle, + sc.show.posterImg as posterImg, + v.name as venueName, + sc.show.startTime as startTime, + sc.show.endTime as endTime + from ShowCasting sc + left join Venue v on v.id = sc.show.venueId + where sc.artist.id in :artistIds + order by + sc.artist.id asc, + case + when sc.show.startTime is null then 1 + else 0 + end asc, + sc.show.startTime desc, + sc.show.id desc + """) + List findAppearanceInfoByArtistIds(@Param("artistIds") List artistIds); +} \ No newline at end of file diff --git a/musical/src/main/java/com/truve/platform/musical/show/repository/ShowRepository.java b/musical/src/main/java/com/truve/platform/musical/show/repository/ShowRepository.java index a07a2f9f..c773d51e 100644 --- a/musical/src/main/java/com/truve/platform/musical/show/repository/ShowRepository.java +++ b/musical/src/main/java/com/truve/platform/musical/show/repository/ShowRepository.java @@ -1,6 +1,8 @@ package com.truve.platform.musical.show.repository; import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -13,6 +15,19 @@ import com.truve.platform.musical.show.domain.entity.Show; public interface ShowRepository extends JpaRepository { + interface ShowSearchProjection { + Long getShowId(); + + String getPosterImg(); + + String getTitle(); + + String getVenueName(); + + LocalDateTime getStartTime(); + + LocalDateTime getEndTime(); + } @Query(""" select s @@ -126,11 +141,41 @@ Page findHomeShowsOrderByReviewCount( from Show p where p.id = :showId """) - java.util.Optional findDetailById(@Param("showId") Long showId); + Optional findDetailById(@Param("showId") Long showId); default Show findByIdOrThrow(Long showId) { return findDetailById(showId).orElseThrow( () -> new CustomException(ErrorCode.NOT_FOUND_SHOW) ); } -} + + @Query(""" + select + s.id as showId, + s.posterImg as posterImg, + s.title as title, + v.name as venueName, + s.startTime as startTime, + s.endTime as endTime + from Show s + left join Venue v on v.id = s.venueId + where s.title like concat('%', :keyword, '%') + and (s.endTime is null or s.endTime >= :now) + order by + case + when lower(s.title) = lower(:keyword) then 0 + else 1 + end asc, + case + when s.startTime is null then 1 + else 0 + end asc, + s.startTime desc, + s.id desc + """) + List searchShows( + @Param("keyword") String keyword, + @Param("now") LocalDateTime now + ); + +} \ No newline at end of file diff --git a/musical/src/main/java/com/truve/platform/musical/show/service/SearchService.java b/musical/src/main/java/com/truve/platform/musical/show/service/SearchService.java new file mode 100644 index 00000000..52fd1fea --- /dev/null +++ b/musical/src/main/java/com/truve/platform/musical/show/service/SearchService.java @@ -0,0 +1,242 @@ +package com.truve.platform.musical.show.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import com.truve.platform.musical.s3.S3Service; +import com.truve.platform.musical.show.dto.SearchResponse; +import com.truve.platform.musical.show.repository.ArtistRepository; +import com.truve.platform.musical.show.repository.ShowCastingRepository; +import com.truve.platform.musical.show.repository.ShowRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SearchService { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + private static final String DEFAULT_DATE = "기간 미정"; + private static final String DEFAULT_SHOW_TITLE = "작품명 미정"; + private static final String DEFAULT_VENUE_NAME = "공연장 정보 없음"; + private static final String DEFAULT_APPEARANCE_INFO = "출연 정보 없음"; + private static final int MAX_APPEARANCE_SHOWS = 2; + + private final ShowRepository showRepository; + private final ArtistRepository artistRepository; + private final ShowCastingRepository showCastingRepository; + private final S3Service s3Service; + + @Transactional(readOnly = true) + public SearchResponse.SearchResult search( + String keyword, + int artistOffset, + int artistLimit, + int showOffset, + int showLimit + ) { + String trimmedKeyword = trimKeyword(keyword); + if (!StringUtils.hasText(trimmedKeyword)) { + return SearchResponse.SearchResult.empty(trimmedKeyword); + } + + LocalDateTime now = LocalDate.now().atStartOfDay(); + + List allShowProjections = showRepository.searchShows(trimmedKeyword, now); + List allArtistProjections = artistRepository.searchArtists(trimmedKeyword); + List allAppearances = findAppearances(allArtistProjections); + + int totalArtistCount = allArtistProjections.size(); + List paginatedArtistProjections = allArtistProjections.stream() + .skip(artistOffset) + .limit(artistLimit) + .toList(); + + Set paginatedArtistIds = paginatedArtistProjections.stream() + .map(ArtistRepository.ArtistSearchProjection::getArtistId) + .collect(Collectors.toSet()); + + List paginatedAppearances = allAppearances.stream() + .filter(app -> paginatedArtistIds.contains(app.getArtistId())) + .toList(); + + Map appearanceInfoByArtistId = buildAppearanceInfoByArtistId(paginatedArtistProjections, paginatedAppearances); + + LinkedHashMap allShowsMap = buildShowsMap(allShowProjections, allAppearances, now); + int totalShowCount = allShowsMap.size(); + + List paginatedShows = allShowsMap.values().stream() + .skip(showOffset) + .limit(showLimit) + .toList(); + + List artists = paginatedArtistProjections.stream() + .map(artist -> SearchResponse.ArtistSummary.of( + artist.getArtistId(), + artist.getArtistName(), + toImageUrl(artist.getProfileImg()), + appearanceInfoByArtistId.getOrDefault(artist.getArtistId(), DEFAULT_APPEARANCE_INFO) + )) + .toList(); + + boolean hasMoreArtists = (artistOffset + artists.size()) < totalArtistCount; + boolean hasMoreShows = (showOffset + paginatedShows.size()) < totalShowCount; + + return SearchResponse.SearchResult.of( + trimmedKeyword, + artists, + paginatedShows, + totalArtistCount, + totalShowCount, + hasMoreArtists, + hasMoreShows + ); + } + + private List findAppearances( + List artists + ) { + if (artists == null || artists.isEmpty()) { + return List.of(); + } + + List artistIds = artists.stream() + .map(ArtistRepository.ArtistSearchProjection::getArtistId) + .toList(); + return showCastingRepository.findAppearanceInfoByArtistIds(artistIds); + } + + private Map buildAppearanceInfoByArtistId( + List artists, + List appearances + ) { + if (artists.isEmpty()) { + return Collections.emptyMap(); + } + + List artistIds = artists.stream() + .map(ArtistRepository.ArtistSearchProjection::getArtistId) + .toList(); + Map result = new LinkedHashMap<>(); + artistIds.forEach(artistId -> result.put(artistId, DEFAULT_APPEARANCE_INFO)); + + if (appearances.isEmpty()) { + return result; + } + + Map> byArtist = new LinkedHashMap<>(); + for (ShowCastingRepository.ArtistAppearanceProjection appearance : appearances) { + byArtist.computeIfAbsent(appearance.getArtistId(), ignored -> new LinkedHashMap<>()) + .putIfAbsent(appearance.getShowId(), appearance); + } + + for (Long artistId : artistIds) { + Map showMap = byArtist.get(artistId); + if (showMap == null || showMap.isEmpty()) { + continue; + } + + String labels = showMap.values().stream() + .limit(MAX_APPEARANCE_SHOWS) + .map(this::toAppearanceLabel) + .collect(Collectors.joining(", ")); + String info = labels.isEmpty() ? DEFAULT_APPEARANCE_INFO : "출연: " + labels; + result.put(artistId, info); + } + return result; + } + + private LinkedHashMap buildShowsMap( + List showProjections, + List appearances, + LocalDateTime now + ) { + LinkedHashMap dedup = new LinkedHashMap<>(); + + showProjections.forEach(show -> dedup.put( + show.getShowId(), + toShowSummary(show.getShowId(), show.getPosterImg(), show.getTitle(), + show.getVenueName(), show.getStartTime(), show.getEndTime()) + )); + + if (!appearances.isEmpty()) { + appearances.forEach(appearance -> { + if (isEnded(appearance.getEndTime(), now)) { + return; + } + dedup.putIfAbsent( + appearance.getShowId(), + toShowSummary(appearance.getShowId(), appearance.getPosterImg(), appearance.getShowTitle(), + appearance.getVenueName(), appearance.getStartTime(), appearance.getEndTime()) + ); + }); + } + + return dedup; + } + + private SearchResponse.ShowSummary toShowSummary( + Long showId, + String posterImg, + String title, + String venueName, + LocalDateTime startTime, + LocalDateTime endTime + ) { + return SearchResponse.ShowSummary.of( + showId, + toImageUrl(posterImg), + withDefaultShowTitle(title), + withDefaultVenueName(venueName), + toDateRange(startTime, endTime) + ); + } + + private String toAppearanceLabel(ShowCastingRepository.ArtistAppearanceProjection appearance) { + String showTitle = withDefaultShowTitle(appearance.getShowTitle()); + String year = appearance.getStartTime() != null ? String.valueOf(appearance.getStartTime().getYear()) : "미정"; + return "뮤지컬 <" + showTitle + ">(" + year + ")"; + } + + private String withDefaultShowTitle(String showTitle) { + return StringUtils.hasText(showTitle) ? showTitle : DEFAULT_SHOW_TITLE; + } + + private String trimKeyword(String keyword) { + return keyword == null ? "" : keyword.trim(); + } + + private String toImageUrl(String imageKey) { + if (!StringUtils.hasText(imageKey)) { + return null; + } + return s3Service.getImageUrl(imageKey); + } + + private String toDateRange(LocalDateTime startTime, LocalDateTime endTime) { + if (startTime == null || endTime == null) { + return DEFAULT_DATE; + } + return DATE_FORMATTER.format(startTime) + " - " + DATE_FORMATTER.format(endTime); + } + + private String withDefaultVenueName(String venueName) { + return StringUtils.hasText(venueName) ? venueName : DEFAULT_VENUE_NAME; + } + + private boolean isEnded(LocalDateTime endTime, LocalDateTime now) { + return endTime != null && endTime.isBefore(now); + } + +} \ No newline at end of file diff --git a/musical/src/test/java/com/truve/platform/musical/show/controller/SearchControllerTest.java b/musical/src/test/java/com/truve/platform/musical/show/controller/SearchControllerTest.java new file mode 100644 index 00000000..3aae370e --- /dev/null +++ b/musical/src/test/java/com/truve/platform/musical/show/controller/SearchControllerTest.java @@ -0,0 +1,96 @@ +package com.truve.platform.musical.show.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.truve.platform.common.exception.ApiAdvice; +import com.truve.platform.musical.MusicalApplication; +import com.truve.platform.musical.show.dto.SearchResponse; +import com.truve.platform.musical.show.service.SearchService; + +@WebMvcTest(controllers = SearchController.class) +@org.springframework.context.annotation.Import(ApiAdvice.class) +@ContextConfiguration(classes = MusicalApplication.class) +class SearchControllerTest { + + @Autowired + private MockMvc mockMvc; + @MockitoBean + private SearchService searchService; + @MockitoBean + private JpaMetamodelMappingContext jpaMetamodelMappingContext; + + @Test + @DisplayName("검색 조회에 성공하면 200과 검색 결과를 응답한다.") + void 검색_조회_성공() throws Exception { + SearchResponse.SearchResult response = SearchResponse.SearchResult.builder() + .keyword("김호") + .artistCount(2) + .showCount(1) + .hasMoreArtists(false) + .hasMoreShows(false) + .artists(List.of( + SearchResponse.ArtistSummary.builder() + .artistId(101L) + .artistName("김호영") + .profileImageUrl(null) + .appearanceInfo("출연: 뮤지컬 <테스트 검색 공연>(2026)") + .build(), + SearchResponse.ArtistSummary.builder() + .artistId(109L) + .artistName("김호석") + .profileImageUrl(null) + .appearanceInfo("출연 정보 없음") + .build() + )) + .shows(List.of( + SearchResponse.ShowSummary.builder() + .showId(6L) + .posterUrl("http://localstack:4566/truve-media/shows/search-test-poster.jpg") + .title("테스트 검색 공연") + .venueName("Blue Square") + .date("2026.03.20 - 2026.06.30") + .build() + )) + .build(); + + given(searchService.search("김호", 0, 20, 0, 20)).willReturn(response); + + mockMvc.perform(get("/api/musical/search").param("keyword", "김호")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("ok")) + .andExpect(jsonPath("$.data.keyword").value("김호")) + .andExpect(jsonPath("$.data.artistCount").value(2)) + .andExpect(jsonPath("$.data.showCount").value(1)) + .andExpect(jsonPath("$.data.hasMoreArtists").value(false)) + .andExpect(jsonPath("$.data.hasMoreShows").value(false)) + .andExpect(jsonPath("$.data.artists[0].artistName").value("김호영")) + .andExpect(jsonPath("$.data.shows[0].title").value("테스트 검색 공연")); + } + + @Test + @DisplayName("검색 처리 중 예외가 발생하면 공통 500(C01)을 응답한다.") + void 검색_조회_실패_서버에러() throws Exception { + willThrow(new RuntimeException("db error")) + .given(searchService).search("김호", 0, 20, 0, 20); + + mockMvc.perform(get("/api/musical/search").param("keyword", "김호")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value("C01")) + .andExpect(jsonPath("$.errorType").value("SERVER_ERROR")); + } +} \ No newline at end of file diff --git a/musical/src/test/java/com/truve/platform/show/service/service/SearchServiceTest.java b/musical/src/test/java/com/truve/platform/show/service/service/SearchServiceTest.java new file mode 100644 index 00000000..221daa3c --- /dev/null +++ b/musical/src/test/java/com/truve/platform/show/service/service/SearchServiceTest.java @@ -0,0 +1,98 @@ +package com.truve.platform.show.service.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.truve.platform.musical.s3.S3Service; +import com.truve.platform.musical.show.dto.SearchResponse; +import com.truve.platform.musical.show.repository.ArtistRepository; +import com.truve.platform.musical.show.repository.ShowCastingRepository; +import com.truve.platform.musical.show.repository.ShowRepository; +import com.truve.platform.musical.show.service.SearchService; + +@ExtendWith(MockitoExtension.class) +class SearchServiceTest { + + @Mock + private ShowRepository showRepository; + @Mock + private ArtistRepository artistRepository; + @Mock + private ShowCastingRepository showCastingRepository; + @Mock + private S3Service s3Service; + + @InjectMocks + private SearchService searchService; + + @Test + @DisplayName("공백 검색어는 빈 결과를 응답한다.") + void 공백_검색어_빈결과_응답() { + SearchResponse.SearchResult result = searchService.search(" ", 0, 20, 0, 20); + + assertEquals("", result.getKeyword()); + assertEquals(0, result.getArtistCount()); + assertEquals(0, result.getShowCount()); + assertTrue(result.getArtists().isEmpty()); + assertTrue(result.getShows().isEmpty()); + + verifyNoInteractions(showRepository, artistRepository, showCastingRepository, s3Service); + } + + @Test + @DisplayName("배우명 검색 시 출연작 공연도 shows에 포함한다.") + void 배우명_검색_출연작_공연_포함() { + ArtistRepository.ArtistSearchProjection artistProjection = org.mockito.Mockito.mock( + ArtistRepository.ArtistSearchProjection.class + ); + when(artistProjection.getArtistId()).thenReturn(101L); + when(artistProjection.getArtistName()).thenReturn("김호영"); + when(artistProjection.getProfileImg()).thenReturn(null); + + ShowCastingRepository.ArtistAppearanceProjection appearance = org.mockito.Mockito.mock( + ShowCastingRepository.ArtistAppearanceProjection.class + ); + when(appearance.getArtistId()).thenReturn(101L); + when(appearance.getShowId()).thenReturn(6L); + when(appearance.getShowTitle()).thenReturn("테스트 검색 공연"); + when(appearance.getPosterImg()).thenReturn("shows/search-test-poster.jpg"); + when(appearance.getVenueName()).thenReturn("Blue Square"); + when(appearance.getStartTime()).thenReturn(LocalDateTime.of(2026, 3, 20, 19, 0)); + when(appearance.getEndTime()).thenReturn(LocalDateTime.of(2026, 6, 30, 23, 59)); + + when(showRepository.searchShows(org.mockito.ArgumentMatchers.eq("김호"), org.mockito.ArgumentMatchers.any(LocalDateTime.class))) + .thenReturn(List.of()); + when(artistRepository.searchArtists("김호")).thenReturn(List.of(artistProjection)); + when(showCastingRepository.findAppearanceInfoByArtistIds(List.of(101L))).thenReturn(List.of(appearance)); + when(s3Service.getImageUrl("shows/search-test-poster.jpg")) + .thenReturn("http://localstack:4566/truve-media/shows/search-test-poster.jpg"); + + SearchResponse.SearchResult result = searchService.search("김호", 0, 20, 0, 20); + + assertEquals("김호", result.getKeyword()); + assertEquals(1, result.getArtistCount()); + assertEquals(1, result.getShowCount()); + assertEquals(1, result.getArtists().size()); + assertEquals("김호영", result.getArtists().get(0).getArtistName()); + assertEquals("출연: 뮤지컬 <테스트 검색 공연>(2026)", result.getArtists().get(0).getAppearanceInfo()); + assertEquals(1, result.getShows().size()); + assertEquals(6L, result.getShows().get(0).getShowId()); + assertEquals("테스트 검색 공연", result.getShows().get(0).getTitle()); + assertEquals("Blue Square", result.getShows().get(0).getVenueName()); + assertFalse(result.isHasMoreArtists()); + assertFalse(result.isHasMoreShows()); + } +} \ No newline at end of file