diff --git a/src/main/java/com/Coming/Backend/concert/controller/ConcertController.java b/src/main/java/com/Coming/Backend/concert/controller/ConcertController.java index aee1ca03..4640a3c6 100644 --- a/src/main/java/com/Coming/Backend/concert/controller/ConcertController.java +++ b/src/main/java/com/Coming/Backend/concert/controller/ConcertController.java @@ -55,7 +55,7 @@ public class ConcertController { @GetMapping public ResponseEntity> getConcerts( @RequestParam(required = false) String q, - @RequestParam(required = false) ConcertStatus status, + @RequestParam(required = false) List status, @RequestParam(required = false) Boolean inCalendar, @RequestParam(required = false) Boolean followedOnly, @RequestParam(required = false) Boolean ticketOpenPending, 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 559824b5..02f7322d 100644 --- a/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java +++ b/src/main/java/com/Coming/Backend/concert/repository/ConcertRepository.java @@ -84,7 +84,7 @@ public interface ConcertRepository extends JpaRepository { @Query(value = """ SELECT c FROM Concert c WHERE c.status NOT IN :hidden - AND (:status IS NULL OR c.status = :status) + AND (:statuses IS NULL OR c.status IN :statuses) AND (:calendarUserId IS NULL OR c.id IN ( SELECT ucc.concertId FROM UserConcertCalendar ucc WHERE ucc.userId = :calendarUserId )) @@ -96,7 +96,7 @@ public interface ConcertRepository extends JpaRepository { countQuery = """ SELECT COUNT(c) FROM Concert c WHERE c.status NOT IN :hidden - AND (:status IS NULL OR c.status = :status) + AND (:statuses IS NULL OR c.status IN :statuses) AND (:calendarUserId IS NULL OR c.id IN ( SELECT ucc.concertId FROM UserConcertCalendar ucc WHERE ucc.userId = :calendarUserId )) @@ -106,7 +106,7 @@ SELECT COUNT(c) FROM Concert c AND (:ticketOpenPending = false OR (c.ticketOpenAt IS NOT NULL AND c.ticketOpenAt > :now)) """) Page findConcerts(@Param("hidden") Collection hidden, - @Param("status") ConcertStatus status, + @Param("statuses") Collection statuses, @Param("calendarUserId") Long calendarUserId, @Param("followedArtistIds") List followedArtistIds, @Param("ticketOpenPending") boolean ticketOpenPending, @@ -119,7 +119,7 @@ Page findConcerts(@Param("hidden") Collection hidden, @Query(value = """ SELECT DISTINCT c FROM Concert c WHERE c.status NOT IN :hidden - AND (:status IS NULL OR c.status = :status) + AND (:statuses IS NULL OR c.status IN :statuses) AND (:calendarUserId IS NULL OR c.id IN ( SELECT ucc.concertId FROM UserConcertCalendar ucc WHERE ucc.userId = :calendarUserId )) @@ -143,7 +143,7 @@ SELECT al.artistId FROM ArtistAlias al WHERE LOWER(al.name) LIKE :q countQuery = """ SELECT COUNT(DISTINCT c) FROM Concert c WHERE c.status NOT IN :hidden - AND (:status IS NULL OR c.status = :status) + AND (:statuses IS NULL OR c.status IN :statuses) AND (:calendarUserId IS NULL OR c.id IN ( SELECT ucc.concertId FROM UserConcertCalendar ucc WHERE ucc.userId = :calendarUserId )) @@ -165,7 +165,7 @@ SELECT al.artistId FROM ArtistAlias al WHERE LOWER(al.name) LIKE :q ) """) Page searchConcerts(@Param("hidden") Collection hidden, - @Param("status") ConcertStatus status, + @Param("statuses") Collection statuses, @Param("calendarUserId") Long calendarUserId, @Param("followedArtistIds") List followedArtistIds, @Param("ticketOpenPending") boolean ticketOpenPending, diff --git a/src/main/java/com/Coming/Backend/concert/service/ConcertService.java b/src/main/java/com/Coming/Backend/concert/service/ConcertService.java index 62f82787..6781e289 100644 --- a/src/main/java/com/Coming/Backend/concert/service/ConcertService.java +++ b/src/main/java/com/Coming/Backend/concert/service/ConcertService.java @@ -35,6 +35,7 @@ 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; @@ -138,13 +139,13 @@ public ConcertStatsResponse getConcertStats(int year, int month) { *

모든 필터는 AND로 조합된다. EXCLUDED·PENDING은 항상 숨겨지므로 status로 요청해도 빈 페이지가 반환된다.

* * @param q 검색어 (공연명, 아티스트명, alias 대소문자 무시 부분 일치). null·공백이면 텍스트 조건 없이 나머지 필터만 적용한다. - * @param status null이면 전체 조회 + * @param statuses null·빈 리스트이면 전체 조회. 여러 값이면 그중 하나에 해당하는 공연을 반환한다 * @param inCalendar true이면 내 캘린더에 추가한 공연만 반환 (미인증 시 빈 페이지) * @param followedOnly true이면 팔로우한 아티스트의 공연만 반환 (미인증·팔로잉 없으면 빈 페이지) * @param ticketOpenPending true이면 티켓 오픈 예정(ticketOpenAt > now) 공연만 반환 * @param userId 인증 사용자 ID (null이면 isInCalendar 전부 false) */ - public PageResponse getConcerts(String q, ConcertStatus status, Boolean inCalendar, Boolean followedOnly, + public PageResponse getConcerts(String q, List statuses, Boolean inCalendar, Boolean followedOnly, Boolean ticketOpenPending, Pageable pageable, Long userId) { if (Boolean.TRUE.equals(inCalendar) && userId == null) { return emptyPage(pageable); @@ -157,12 +158,15 @@ public PageResponse getConcerts(String q, ConcertStatus } List followedArtistIds = followedResolution.orElse(null); + List statusFilter = statuses == null || statuses.isEmpty() ? null : statuses; boolean pending = Boolean.TRUE.equals(ticketOpenPending); LocalDateTime now = LocalDateTime.now(); + // 정렬 값이 같은 공연이 페이지 경계에 걸려도 중복·누락되지 않도록 id를 보조 정렬로 붙인다 + Pageable stablePageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), pageable.getSort().and(Sort.by("id"))); boolean hasQuery = q != null && !q.isBlank(); Page page = hasQuery - ? concertRepository.searchConcerts(HIDDEN_STATUSES, status, calendarUserId, followedArtistIds, pending, now, "%" + q.toLowerCase() + "%", pageable) - : concertRepository.findConcerts(HIDDEN_STATUSES, status, calendarUserId, followedArtistIds, pending, now, pageable); + ? concertRepository.searchConcerts(HIDDEN_STATUSES, statusFilter, calendarUserId, followedArtistIds, pending, now, "%" + q.toLowerCase() + "%", stablePageable) + : concertRepository.findConcerts(HIDDEN_STATUSES, statusFilter, calendarUserId, followedArtistIds, pending, now, stablePageable); List content = toConcertSummaryList(page.getContent(), userId); return new PageResponse<>(content, page.getNumber(), page.getSize(), page.getTotalElements(), page.getTotalPages()); diff --git a/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java b/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java index 6bc59af9..0b523070 100644 --- a/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java +++ b/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java @@ -1,11 +1,13 @@ package com.Coming.Backend.concert.controller; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; @@ -31,10 +33,12 @@ import org.junit.jupiter.api.BeforeEach; 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.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -128,7 +132,7 @@ void should_return_200_with_concerts_when_no_query_params_given() throws Excepti void should_pass_all_query_params_to_service_when_all_filters_given() throws Exception { // given PageResponse emptyPage = new PageResponse<>(List.of(), 0, 20, 0, 0); - given(concertService.getConcerts(eq("IU"), eq(ConcertStatus.UPCOMING), eq(true), eq(true), eq(true), any(Pageable.class), isNull())) + given(concertService.getConcerts(eq("IU"), eq(List.of(ConcertStatus.UPCOMING)), eq(true), eq(true), eq(true), any(Pageable.class), isNull())) .willReturn(emptyPage); // when & then @@ -140,7 +144,7 @@ void should_pass_all_query_params_to_service_when_all_filters_given() throws Exc .param("ticketOpenPending", "true") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()); - verify(concertService).getConcerts(eq("IU"), eq(ConcertStatus.UPCOMING), eq(true), eq(true), eq(true), any(Pageable.class), isNull()); + verify(concertService).getConcerts(eq("IU"), eq(List.of(ConcertStatus.UPCOMING)), eq(true), eq(true), eq(true), any(Pageable.class), isNull()); } @Test @@ -174,6 +178,72 @@ void should_return_400_when_sort_property_is_not_allowed() throws Exception { .andExpect(jsonPath("$.message").exists()); } + @Test + void should_pass_status_list_to_service_when_repeated_status_params_given() throws Exception { + // given + List statuses = List.of(ConcertStatus.UPCOMING, ConcertStatus.ONGOING); + PageResponse emptyPage = new PageResponse<>(List.of(), 0, 20, 0, 0); + given(concertService.getConcerts(isNull(), eq(statuses), isNull(), isNull(), isNull(), any(Pageable.class), isNull())) + .willReturn(emptyPage); + + // when & then + mockMvc.perform(get("/api/concerts") + .param("status", "UPCOMING") + .param("status", "ONGOING") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + verify(concertService).getConcerts(isNull(), eq(statuses), isNull(), isNull(), isNull(), any(Pageable.class), isNull()); + } + + @Test + void should_pass_status_list_to_service_when_comma_separated_status_given() throws Exception { + // given + List statuses = List.of(ConcertStatus.UPCOMING, ConcertStatus.ONGOING); + PageResponse emptyPage = new PageResponse<>(List.of(), 0, 20, 0, 0); + given(concertService.getConcerts(isNull(), eq(statuses), isNull(), isNull(), isNull(), any(Pageable.class), isNull())) + .willReturn(emptyPage); + + // when & then + mockMvc.perform(get("/api/concerts") + .param("status", "UPCOMING,ONGOING") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + verify(concertService).getConcerts(isNull(), eq(statuses), isNull(), isNull(), isNull(), any(Pageable.class), isNull()); + } + + @Test + void should_return_400_when_invalid_status_value_given() throws Exception { + // when & then + mockMvc.perform(get("/api/concerts") + .param("status", "UPCOMING") + .param("status", "FOO") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + verifyNoInteractions(concertService); + } + + @Test + void should_pass_start_date_asc_sort_to_service_when_sort_param_given() throws Exception { + // given + PageResponse emptyPage = new PageResponse<>(List.of(), 0, 20, 0, 0); + given(concertService.getConcerts(isNull(), isNull(), isNull(), isNull(), isNull(), any(Pageable.class), isNull())) + .willReturn(emptyPage); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + + // when + mockMvc.perform(get("/api/concerts") + .param("sort", "startDate,asc") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + // then + verify(concertService).getConcerts(isNull(), isNull(), isNull(), isNull(), isNull(), pageableCaptor.capture(), isNull()); + assertThat(pageableCaptor.getValue().getSort()) + .isEqualTo(Sort.by(Sort.Direction.ASC, "startDate")); + } + // ------------------------------------------------------------------------- // 오류 응답 형식 검증 // ------------------------------------------------------------------------- diff --git a/src/test/java/com/Coming/Backend/concert/repository/ConcertRepositoryTest.java b/src/test/java/com/Coming/Backend/concert/repository/ConcertRepositoryTest.java new file mode 100644 index 00000000..b0806fab --- /dev/null +++ b/src/test/java/com/Coming/Backend/concert/repository/ConcertRepositoryTest.java @@ -0,0 +1,170 @@ +package com.Coming.Backend.concert.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.concert.entity.Concert; +import com.Coming.Backend.concert.entity.ConcertStatus; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +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; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ConcertRepositoryTest { + + @Autowired + private ConcertRepository concertRepository; + + private static final List HIDDEN = List.of(ConcertStatus.EXCLUDED, ConcertStatus.PENDING); + + // 로컬 DB의 기존 공연보다 앞에 오도록 먼 미래 날짜로 저장하고 startDate DESC로 조회한다 + private static final LocalDate FAR_FUTURE = LocalDate.of(2999, 12, 31); + private static final Pageable FAR_FUTURE_FIRST = PageRequest.of(0, 50, + Sort.by(Sort.Direction.DESC, "startDate").and(Sort.by("id"))); + + private final String uniqueTitle = "issue137-" + UUID.randomUUID(); + + private Concert saveConcert(ConcertStatus status) { + return saveConcert(status, FAR_FUTURE); + } + + private Concert saveConcert(ConcertStatus status, LocalDate startDate) { + return concertRepository.save(Concert.builder() + .title(uniqueTitle + " " + status.name()) + .startDate(startDate) + .endDate(startDate) + .venueName("올림픽공원 KSPO DOME") + .status(status) + .viewCount(0L) + .kopisUpdateDate(LocalDate.of(2026, 10, 1)) + .build()); + } + + private Page findConcerts(List statuses) { + return concertRepository.findConcerts(HIDDEN, statuses, null, null, false, LocalDateTime.now(), FAR_FUTURE_FIRST); + } + + private String likeQuery() { + return "%" + uniqueTitle.toLowerCase(Locale.ROOT) + "%"; + } + + @Test + void should_return_concerts_of_all_given_statuses_when_multiple_statuses_given() { + // given + Concert upcoming = saveConcert(ConcertStatus.UPCOMING); + Concert ongoing = saveConcert(ConcertStatus.ONGOING); + Concert ended = saveConcert(ConcertStatus.ENDED); + + // when + Page result = findConcerts(List.of(ConcertStatus.UPCOMING, ConcertStatus.ONGOING)); + + // then + assertThat(result.getContent()).extracting(Concert::getId) + .contains(upcoming.getId(), ongoing.getId()) + .doesNotContain(ended.getId()); + } + + @Test + void should_return_only_matching_status_when_single_status_given() { + // given + Concert upcoming = saveConcert(ConcertStatus.UPCOMING); + Concert ongoing = saveConcert(ConcertStatus.ONGOING); + Concert ended = saveConcert(ConcertStatus.ENDED); + + // when + Page result = findConcerts(List.of(ConcertStatus.UPCOMING)); + + // then + assertThat(result.getContent()).extracting(Concert::getId) + .contains(upcoming.getId()) + .doesNotContain(ongoing.getId(), ended.getId()); + } + + @Test + void should_return_all_visible_statuses_when_statuses_is_null() { + // given + Concert upcoming = saveConcert(ConcertStatus.UPCOMING); + Concert ongoing = saveConcert(ConcertStatus.ONGOING); + Concert ended = saveConcert(ConcertStatus.ENDED); + Concert cancelled = saveConcert(ConcertStatus.CANCELLED); + Concert excluded = saveConcert(ConcertStatus.EXCLUDED); + Concert pending = saveConcert(ConcertStatus.PENDING); + + // when + Page result = findConcerts(null); + + // then + assertThat(result.getContent()).extracting(Concert::getId) + .contains(upcoming.getId(), ongoing.getId(), ended.getId(), cancelled.getId()) + .doesNotContain(excluded.getId(), pending.getId()); + } + + @Test + void should_exclude_hidden_concerts_when_hidden_statuses_requested() { + // given + Concert upcoming = saveConcert(ConcertStatus.UPCOMING); + Concert excluded = saveConcert(ConcertStatus.EXCLUDED); + Concert pending = saveConcert(ConcertStatus.PENDING); + + // when + Page result = findConcerts(List.of(ConcertStatus.UPCOMING, ConcertStatus.EXCLUDED, ConcertStatus.PENDING)); + + // then + assertThat(result.getContent()).extracting(Concert::getId) + .contains(upcoming.getId()) + .doesNotContain(excluded.getId(), pending.getId()); + } + + @Test + void should_return_matching_concerts_and_total_count_when_search_with_multiple_statuses() { + // given + Concert upcoming = saveConcert(ConcertStatus.UPCOMING); + saveConcert(ConcertStatus.ONGOING); + saveConcert(ConcertStatus.ENDED); + saveConcert(ConcertStatus.EXCLUDED); + + // when + Page result = concertRepository.searchConcerts(HIDDEN, List.of(ConcertStatus.UPCOMING, ConcertStatus.ONGOING), + null, null, false, LocalDateTime.now(), likeQuery(), PageRequest.of(0, 1, Sort.by("id"))); + + // then + assertThat(result.getContent()).extracting(Concert::getId).containsExactly(upcoming.getId()); + assertThat(result.getTotalElements()).isEqualTo(2); + assertThat(result.getTotalPages()).isEqualTo(2); + } + + @Test + void should_return_all_concerts_in_id_order_without_duplicates_when_paging_concerts_with_same_start_date() { + // given + LocalDate sameDate = LocalDate.of(2027, 5, 1); + List savedIds = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + savedIds.add(saveConcert(ConcertStatus.UPCOMING, sameDate).getId()); + } + Sort sort = Sort.by("startDate").and(Sort.by("id")); + + // when + List pagedIds = new ArrayList<>(); + for (int page = 0; page < 3; page++) { + Page result = concertRepository.searchConcerts(HIDDEN, null, null, null, false, LocalDateTime.now(), + likeQuery(), PageRequest.of(page, 2, sort)); + result.getContent().forEach(concert -> pagedIds.add(concert.getId())); + } + + // then + assertThat(pagedIds).containsExactlyElementsOf(savedIds.stream().sorted().toList()); + } +} diff --git a/src/test/java/com/Coming/Backend/concert/service/ConcertServiceTest.java b/src/test/java/com/Coming/Backend/concert/service/ConcertServiceTest.java index 97ca2aeb..61ce438f 100644 --- a/src/test/java/com/Coming/Backend/concert/service/ConcertServiceTest.java +++ b/src/test/java/com/Coming/Backend/concert/service/ConcertServiceTest.java @@ -59,6 +59,7 @@ 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 ConcertServiceTest { @@ -103,6 +104,7 @@ class ConcertServiceTest { private static final Long ARTIST_ID = 10L; private static final Long USER_ID = 100L; private static final Pageable PAGEABLE = PageRequest.of(0, 20); + private static final Pageable STABLE_PAGEABLE = PageRequest.of(0, 20, Sort.by("id")); @BeforeEach void setUp() { @@ -319,7 +321,7 @@ void should_call_find_concerts_when_q_is_not_given() { ConcertArtist concertArtist = buildConcertArtist(CONCERT_ID, ARTIST_ID); Artist artist = buildArtist(ARTIST_ID, "YOASOBI"); - given(concertRepository.findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(PAGEABLE))) + given(concertRepository.findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE))) .willReturn(page); given(concertArtistRepository.findByConcertIdIn(List.of(CONCERT_ID))) .willReturn(List.of(concertArtist)); @@ -329,7 +331,7 @@ void should_call_find_concerts_when_q_is_not_given() { PageResponse response = concertService.getConcerts(null, null, null, null, null, PAGEABLE, null); // then - verify(concertRepository).findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(PAGEABLE)); + verify(concertRepository).findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE)); verify(concertRepository, never()).searchConcerts(any(), any(), any(), any(), anyBoolean(), any(), any(), any()); assertThat(response.content()).hasSize(1); assertThat(response.content().get(0).artists()).containsExactly(new ArtistSummary(ARTIST_ID, "YOASOBI", null)); @@ -341,7 +343,7 @@ void should_call_search_concerts_with_lowercased_wrapped_query_when_q_is_given() Concert concert = buildConcert(CONCERT_ID, ConcertStatus.UPCOMING); Page page = new PageImpl<>(List.of(concert), PAGEABLE, 1); - given(concertRepository.searchConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq("%iu%"), eq(PAGEABLE))) + given(concertRepository.searchConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq("%iu%"), eq(STABLE_PAGEABLE))) .willReturn(page); given(concertArtistRepository.findByConcertIdIn(List.of(CONCERT_ID))).willReturn(List.of()); @@ -349,7 +351,7 @@ void should_call_search_concerts_with_lowercased_wrapped_query_when_q_is_given() PageResponse response = concertService.getConcerts("IU", null, null, null, null, PAGEABLE, null); // then - verify(concertRepository).searchConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq("%iu%"), eq(PAGEABLE)); + verify(concertRepository).searchConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq("%iu%"), eq(STABLE_PAGEABLE)); verify(concertRepository, never()).findConcerts(any(), any(), any(), any(), anyBoolean(), any(), any()); assertThat(response.content()).hasSize(1); } @@ -358,14 +360,14 @@ void should_call_search_concerts_with_lowercased_wrapped_query_when_q_is_given() void should_pass_status_filter_to_find_concerts_when_status_is_given() { // given Page page = new PageImpl<>(List.of(), PAGEABLE, 0); - given(concertRepository.findConcerts(anyList(), eq(ConcertStatus.UPCOMING), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(PAGEABLE))) + given(concertRepository.findConcerts(anyList(), eq(List.of(ConcertStatus.UPCOMING)), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE))) .willReturn(page); // when - PageResponse response = concertService.getConcerts(null, ConcertStatus.UPCOMING, null, null, null, PAGEABLE, null); + PageResponse response = concertService.getConcerts(null, List.of(ConcertStatus.UPCOMING), null, null, null, PAGEABLE, null); // then - verify(concertRepository).findConcerts(anyList(), eq(ConcertStatus.UPCOMING), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(PAGEABLE)); + verify(concertRepository).findConcerts(anyList(), eq(List.of(ConcertStatus.UPCOMING)), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE)); assertThat(response.content()).isEmpty(); } @@ -373,7 +375,7 @@ void should_pass_status_filter_to_find_concerts_when_status_is_given() { void should_return_empty_page_when_no_concerts_match_filter() { // given Page emptyPage = new PageImpl<>(List.of(), PAGEABLE, 0); - given(concertRepository.findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(PAGEABLE))) + given(concertRepository.findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE))) .willReturn(emptyPage); // when @@ -389,16 +391,77 @@ void should_return_empty_page_when_status_is_excluded() { // given // EXCLUDED는 리포지토리 쿼리 내 status NOT IN :hidden 조건에 의해 항상 걸러진다 Page emptyPage = new PageImpl<>(List.of(), PAGEABLE, 0); - given(concertRepository.findConcerts(anyList(), eq(ConcertStatus.EXCLUDED), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(PAGEABLE))) + given(concertRepository.findConcerts(anyList(), eq(List.of(ConcertStatus.EXCLUDED)), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE))) .willReturn(emptyPage); // when - PageResponse response = concertService.getConcerts(null, ConcertStatus.EXCLUDED, null, null, null, PAGEABLE, null); + PageResponse response = concertService.getConcerts(null, List.of(ConcertStatus.EXCLUDED), null, null, null, PAGEABLE, null); // then assertThat(response.content()).isEmpty(); assertThat(response.totalElements()).isZero(); - verify(concertRepository).findConcerts(anyList(), eq(ConcertStatus.EXCLUDED), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(PAGEABLE)); + verify(concertRepository).findConcerts(anyList(), eq(List.of(ConcertStatus.EXCLUDED)), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE)); + } + + @Test + void should_pass_all_statuses_to_find_concerts_when_multiple_statuses_given() { + // given + List statuses = List.of(ConcertStatus.UPCOMING, ConcertStatus.ONGOING); + Page page = new PageImpl<>(List.of(), PAGEABLE, 0); + given(concertRepository.findConcerts(anyList(), eq(statuses), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE))) + .willReturn(page); + + // when + concertService.getConcerts(null, statuses, null, null, null, PAGEABLE, null); + + // then + verify(concertRepository).findConcerts(anyList(), eq(statuses), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE)); + } + + @Test + void should_pass_null_statuses_to_find_concerts_when_empty_status_list_given() { + // given + Page page = new PageImpl<>(List.of(), PAGEABLE, 0); + given(concertRepository.findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE))) + .willReturn(page); + + // when + concertService.getConcerts(null, List.of(), null, null, null, PAGEABLE, null); + + // then + verify(concertRepository).findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE)); + } + + @Test + void should_append_id_sort_after_requested_sort_when_find_concerts_called_with_start_date_sort() { + // given + Pageable requested = PageRequest.of(1, 10, Sort.by(Sort.Direction.ASC, "startDate")); + Pageable expected = PageRequest.of(1, 10, Sort.by(Sort.Order.asc("startDate"), Sort.Order.asc("id"))); + Page page = new PageImpl<>(List.of(), expected, 0); + given(concertRepository.findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(expected))) + .willReturn(page); + + // when + concertService.getConcerts(null, null, null, null, null, requested, null); + + // then + verify(concertRepository).findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(expected)); + } + + @Test + void should_append_id_sort_after_requested_sort_when_search_concerts_called_with_start_date_sort() { + // given + Pageable requested = PageRequest.of(0, 20, Sort.by(Sort.Direction.ASC, "startDate")); + Pageable expected = PageRequest.of(0, 20, Sort.by(Sort.Order.asc("startDate"), Sort.Order.asc("id"))); + Page page = new PageImpl<>(List.of(), expected, 0); + given(concertRepository.searchConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq("%iu%"), eq(expected))) + .willReturn(page); + + // when + concertService.getConcerts("IU", null, null, null, null, requested, null); + + // then + verify(concertRepository).searchConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq("%iu%"), eq(expected)); } // ------------------------------------------------------------------------- @@ -426,7 +489,7 @@ void should_pass_authenticated_user_id_as_calendar_user_id_when_in_calendar_true Concert concert = buildConcert(CONCERT_ID, ConcertStatus.UPCOMING); Page page = new PageImpl<>(List.of(concert), PAGEABLE, 1); - given(concertRepository.findConcerts(anyList(), isNull(), eq(USER_ID), isNull(), eq(false), any(LocalDateTime.class), eq(PAGEABLE))) + given(concertRepository.findConcerts(anyList(), isNull(), eq(USER_ID), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE))) .willReturn(page); given(concertArtistRepository.findByConcertIdIn(List.of(CONCERT_ID))).willReturn(List.of()); given(userConcertCalendarRepository.findByUserIdAndConcertIdIn(USER_ID, List.of(CONCERT_ID))) @@ -436,7 +499,7 @@ void should_pass_authenticated_user_id_as_calendar_user_id_when_in_calendar_true PageResponse response = concertService.getConcerts(null, null, true, null, null, PAGEABLE, USER_ID); // then - verify(concertRepository).findConcerts(anyList(), isNull(), eq(USER_ID), isNull(), eq(false), any(LocalDateTime.class), eq(PAGEABLE)); + verify(concertRepository).findConcerts(anyList(), isNull(), eq(USER_ID), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE)); assertThat(response.content()).hasSize(1); } @@ -479,7 +542,7 @@ void should_pass_followed_artist_ids_when_followed_only_true_and_user_has_follow Page page = new PageImpl<>(List.of(concert), PAGEABLE, 1); given(userFollowArtistRepository.findByUserId(USER_ID)).willReturn(List.of(follow)); - given(concertRepository.findConcerts(anyList(), isNull(), isNull(), eq(List.of(ARTIST_ID)), eq(false), any(LocalDateTime.class), eq(PAGEABLE))) + given(concertRepository.findConcerts(anyList(), isNull(), isNull(), eq(List.of(ARTIST_ID)), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE))) .willReturn(page); given(concertArtistRepository.findByConcertIdIn(List.of(CONCERT_ID))).willReturn(List.of()); @@ -487,7 +550,7 @@ void should_pass_followed_artist_ids_when_followed_only_true_and_user_has_follow PageResponse response = concertService.getConcerts(null, null, null, true, null, PAGEABLE, USER_ID); // then - verify(concertRepository).findConcerts(anyList(), isNull(), isNull(), eq(List.of(ARTIST_ID)), eq(false), any(LocalDateTime.class), eq(PAGEABLE)); + verify(concertRepository).findConcerts(anyList(), isNull(), isNull(), eq(List.of(ARTIST_ID)), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE)); assertThat(response.content()).hasSize(1); } @@ -499,14 +562,14 @@ void should_pass_followed_artist_ids_when_followed_only_true_and_user_has_follow void should_pass_ticket_open_pending_true_to_repository_when_ticket_open_pending_given() { // given Page page = new PageImpl<>(List.of(), PAGEABLE, 0); - given(concertRepository.findConcerts(anyList(), isNull(), isNull(), isNull(), eq(true), any(LocalDateTime.class), eq(PAGEABLE))) + given(concertRepository.findConcerts(anyList(), isNull(), isNull(), isNull(), eq(true), any(LocalDateTime.class), eq(STABLE_PAGEABLE))) .willReturn(page); // when PageResponse response = concertService.getConcerts(null, null, null, null, true, PAGEABLE, null); // then - verify(concertRepository).findConcerts(anyList(), isNull(), isNull(), isNull(), eq(true), any(LocalDateTime.class), eq(PAGEABLE)); + verify(concertRepository).findConcerts(anyList(), isNull(), isNull(), isNull(), eq(true), any(LocalDateTime.class), eq(STABLE_PAGEABLE)); assertThat(response.content()).isEmpty(); } @@ -514,15 +577,15 @@ void should_pass_ticket_open_pending_true_to_repository_when_ticket_open_pending void should_combine_status_calendar_and_ticket_open_pending_filters_when_given_together() { // given Page page = new PageImpl<>(List.of(), PAGEABLE, 0); - given(concertRepository.findConcerts(anyList(), eq(ConcertStatus.UPCOMING), eq(USER_ID), isNull(), eq(true), any(LocalDateTime.class), eq(PAGEABLE))) + given(concertRepository.findConcerts(anyList(), eq(List.of(ConcertStatus.UPCOMING)), eq(USER_ID), isNull(), eq(true), any(LocalDateTime.class), eq(STABLE_PAGEABLE))) .willReturn(page); // when PageResponse response = concertService.getConcerts( - null, ConcertStatus.UPCOMING, true, null, true, PAGEABLE, USER_ID); + null, List.of(ConcertStatus.UPCOMING), true, null, true, PAGEABLE, USER_ID); // then - verify(concertRepository).findConcerts(anyList(), eq(ConcertStatus.UPCOMING), eq(USER_ID), isNull(), eq(true), any(LocalDateTime.class), eq(PAGEABLE)); + verify(concertRepository).findConcerts(anyList(), eq(List.of(ConcertStatus.UPCOMING)), eq(USER_ID), isNull(), eq(true), any(LocalDateTime.class), eq(STABLE_PAGEABLE)); assertThat(response.content()).isEmpty(); } @@ -540,7 +603,7 @@ void should_return_is_in_calendar_true_when_authenticated_user_has_concert_in_ca .concertId(CONCERT_ID) .build(); - given(concertRepository.findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(PAGEABLE))) + given(concertRepository.findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE))) .willReturn(page); given(concertArtistRepository.findByConcertIdIn(List.of(CONCERT_ID))) .willReturn(List.of()); @@ -560,7 +623,7 @@ void should_return_is_in_calendar_false_when_user_id_is_null_in_concert_list() { Concert concert = buildConcert(CONCERT_ID, ConcertStatus.UPCOMING); Page page = new PageImpl<>(List.of(concert), PAGEABLE, 1); - given(concertRepository.findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(PAGEABLE))) + given(concertRepository.findConcerts(anyList(), isNull(), isNull(), isNull(), eq(false), any(LocalDateTime.class), eq(STABLE_PAGEABLE))) .willReturn(page); given(concertArtistRepository.findByConcertIdIn(List.of(CONCERT_ID))) .willReturn(List.of());