From 5e1f8dfb7bfea1278f9bfd922faa9b200bc93bed Mon Sep 17 00:00:00 2001 From: Guga Date: Mon, 13 Nov 2023 17:47:25 +0900 Subject: [PATCH] =?UTF-8?q?[BE]=20feat:=20=EC=B6=95=EC=A0=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=95=84=ED=84=B0=EB=A7=81=20=EA=B5=AC=ED=98=84(#6?= =?UTF-8?q?02)=20(#603)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: specification 정의 * feat: FestivalFilter 정의 * feat: 축제 진행 상태에 따른 Controller, Service 생성 * refactor: private 생성자를 lombok 을 통해 생성 * chore: 괄호 제거 * chore: 변수 상수화 * chore: given 절 타입 명시 * feat: 축제 조회 ALL 삭제 및 기본값을 진행 중으로 변경 * feat: 축제 당일이 Progress에 포함되도록 변경 및 Spec 리팩터링 * feat: 축제 진행 상황별 정렬 조건 추가 * chore: 메서드 순서 변경 * chore: index 추가 * chore: 에러 메시지 변경 * chore: test 개행 변경 및 변수 재활용 --- .../festago/common/exception/ErrorCode.java | 1 + .../festival/application/FestivalService.java | 7 +- .../festival/repository/FestivalFilter.java | 33 ++++ .../repository/FestivalRepository.java | 3 +- .../repository/FestivalSpecification.java | 38 ++++ .../presentation/FestivalController.java | 9 +- .../db/migration/V9__festival_date_index.sql | 4 + .../application/FestivalServiceTest.java | 17 -- .../festival/domain/FestivalFilterTest.java | 54 ++++++ .../repository/FestivalRepositoryTest.java | 162 ++++++++++++++++++ .../presentation/FestivalControllerTest.java | 21 ++- 11 files changed, 322 insertions(+), 27 deletions(-) create mode 100644 backend/src/main/java/com/festago/festival/repository/FestivalFilter.java create mode 100644 backend/src/main/java/com/festago/festival/repository/FestivalSpecification.java create mode 100644 backend/src/main/resources/db/migration/V9__festival_date_index.sql create mode 100644 backend/src/test/java/com/festago/festival/domain/FestivalFilterTest.java create mode 100644 backend/src/test/java/com/festago/festival/repository/FestivalRepositoryTest.java diff --git a/backend/src/main/java/com/festago/common/exception/ErrorCode.java b/backend/src/main/java/com/festago/common/exception/ErrorCode.java index 9c57b4cab..890c4a099 100644 --- a/backend/src/main/java/com/festago/common/exception/ErrorCode.java +++ b/backend/src/main/java/com/festago/common/exception/ErrorCode.java @@ -29,6 +29,7 @@ public enum ErrorCode { DELETE_CONSTRAINT_STAGE("티켓이 등록된 공연은 삭제할 수 없습니다."), DELETE_CONSTRAINT_SCHOOL("학생 또는 축제에 등록된 학교는 삭제할 수 없습니다."), DUPLICATE_SCHOOL("이미 존재하는 학교 정보입니다."), + INVALID_FESTIVAL_FILTER("유효하지 않은 축제의 필터 값입니다."), // 401 diff --git a/backend/src/main/java/com/festago/festival/application/FestivalService.java b/backend/src/main/java/com/festago/festival/application/FestivalService.java index 4fe7c285f..5b8185718 100644 --- a/backend/src/main/java/com/festago/festival/application/FestivalService.java +++ b/backend/src/main/java/com/festago/festival/application/FestivalService.java @@ -11,6 +11,7 @@ import com.festago.festival.dto.FestivalResponse; import com.festago.festival.dto.FestivalUpdateRequest; import com.festago.festival.dto.FestivalsResponse; +import com.festago.festival.repository.FestivalFilter; import com.festago.festival.repository.FestivalRepository; import com.festago.school.domain.School; import com.festago.school.repository.SchoolRepository; @@ -19,8 +20,8 @@ import java.time.Clock; import java.time.LocalDate; import java.util.List; -import org.springframework.dao.DataIntegrityViolationException; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -49,8 +50,8 @@ private void validate(Festival festival) { } @Transactional(readOnly = true) - public FestivalsResponse findAll() { - List festivals = festivalRepository.findAll(); + public FestivalsResponse findFestivals(FestivalFilter festivalFilter) { + List festivals = festivalRepository.findAll(festivalFilter.getSpecification(LocalDate.now(clock))); return FestivalsResponse.from(festivals); } diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalFilter.java b/backend/src/main/java/com/festago/festival/repository/FestivalFilter.java new file mode 100644 index 000000000..81f60d250 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/FestivalFilter.java @@ -0,0 +1,33 @@ +package com.festago.festival.repository; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.Festival; +import java.time.LocalDate; +import java.util.function.Function; +import org.springframework.data.jpa.domain.Specification; + +public enum FestivalFilter { + PROGRESS(FestivalSpecification::progress), + PLANNED(FestivalSpecification::planned), + END(FestivalSpecification::end); + + private final Function> filter; + + FestivalFilter(Function> filter) { + this.filter = filter; + } + + public static FestivalFilter from(String filterName) { + return switch (filterName.toUpperCase()) { + case "PROGRESS" -> PROGRESS; + case "PLANNED" -> PLANNED; + case "END" -> END; + default -> throw new BadRequestException(ErrorCode.INVALID_FESTIVAL_FILTER); + }; + } + + public Specification getSpecification(LocalDate currentTime) { + return filter.apply(currentTime); + } +} diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java b/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java index 10b3c02c6..032b89a68 100644 --- a/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java +++ b/backend/src/main/java/com/festago/festival/repository/FestivalRepository.java @@ -2,7 +2,8 @@ import com.festago.festival.domain.Festival; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; -public interface FestivalRepository extends JpaRepository { +public interface FestivalRepository extends JpaRepository, JpaSpecificationExecutor { } diff --git a/backend/src/main/java/com/festago/festival/repository/FestivalSpecification.java b/backend/src/main/java/com/festago/festival/repository/FestivalSpecification.java new file mode 100644 index 000000000..50e7efb5c --- /dev/null +++ b/backend/src/main/java/com/festago/festival/repository/FestivalSpecification.java @@ -0,0 +1,38 @@ +package com.festago.festival.repository; + +import com.festago.festival.domain.Festival; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.data.jpa.domain.Specification; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class FestivalSpecification { + + private static final String START_DATE = "startDate"; + private static final String END_DATE = "endDate"; + + public static Specification progress(LocalDate currentTime) { + return (root, query, criteriaBuilder) -> { + query.orderBy(criteriaBuilder.asc(root.get(START_DATE))); + return criteriaBuilder.and( + criteriaBuilder.lessThanOrEqualTo(root.get(START_DATE), currentTime), + criteriaBuilder.greaterThanOrEqualTo(root.get(END_DATE), currentTime) + ); + }; + } + + public static Specification planned(LocalDate currentTime) { + return (root, query, criteriaBuilder) -> { + query.orderBy(criteriaBuilder.asc(root.get(START_DATE))); + return criteriaBuilder.greaterThan(root.get(START_DATE), currentTime); + }; + } + + public static Specification end(LocalDate currentTime) { + return (root, query, criteriaBuilder) -> { + query.orderBy(criteriaBuilder.desc(root.get(END_DATE))); + return criteriaBuilder.lessThan(root.get(END_DATE), currentTime); + }; + } +} diff --git a/backend/src/main/java/com/festago/presentation/FestivalController.java b/backend/src/main/java/com/festago/presentation/FestivalController.java index 67b9c94fd..197914043 100644 --- a/backend/src/main/java/com/festago/presentation/FestivalController.java +++ b/backend/src/main/java/com/festago/presentation/FestivalController.java @@ -3,6 +3,7 @@ import com.festago.festival.application.FestivalService; import com.festago.festival.dto.FestivalDetailResponse; import com.festago.festival.dto.FestivalsResponse; +import com.festago.festival.repository.FestivalFilter; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -10,6 +11,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -21,9 +23,10 @@ public class FestivalController { private final FestivalService festivalService; @GetMapping - @Operation(description = "모든 축제들을 조회한다.", summary = "축제 목록 조회") - public ResponseEntity findAll() { - FestivalsResponse response = festivalService.findAll(); + @Operation(description = "축제를 조건별로 조회한다. PROGRESS: 진행 중, PLANNED: 진행 예정, END: 종료, 기본값 -> 진행 중", summary = "축제 목록 조회") + public ResponseEntity findFestivals( + @RequestParam(defaultValue = "PROGRESS") String festivalFilter) { + FestivalsResponse response = festivalService.findFestivals(FestivalFilter.from(festivalFilter)); return ResponseEntity.ok() .body(response); } diff --git a/backend/src/main/resources/db/migration/V9__festival_date_index.sql b/backend/src/main/resources/db/migration/V9__festival_date_index.sql new file mode 100644 index 000000000..e52df8ad8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__festival_date_index.sql @@ -0,0 +1,4 @@ +create index festival_start_date_index + on festival (start_date); +create index festival_end_date_index + on festival (end_date desc); diff --git a/backend/src/test/java/com/festago/application/FestivalServiceTest.java b/backend/src/test/java/com/festago/application/FestivalServiceTest.java index c8c5c3387..e7c190ef1 100644 --- a/backend/src/test/java/com/festago/application/FestivalServiceTest.java +++ b/backend/src/test/java/com/festago/application/FestivalServiceTest.java @@ -17,7 +17,6 @@ import com.festago.festival.dto.FestivalDetailResponse; import com.festago.festival.dto.FestivalDetailStageResponse; import com.festago.festival.dto.FestivalResponse; -import com.festago.festival.dto.FestivalsResponse; import com.festago.festival.repository.FestivalRepository; import com.festago.school.domain.School; import com.festago.school.repository.SchoolRepository; @@ -61,22 +60,6 @@ class FestivalServiceTest { @InjectMocks FestivalService festivalService; - @Test - void 모든_축제_조회() { - // given - Festival festival1 = FestivalFixture.festival().id(1L).build(); - Festival festival2 = FestivalFixture.festival().id(2L).build(); - given(festivalRepository.findAll()).willReturn(List.of(festival1, festival2)); - - // when - FestivalsResponse response = festivalService.findAll(); - - // then - List festivalIds = response.festivals().stream().map(FestivalResponse::id).toList(); - - assertThat(festivalIds).containsExactly(1L, 2L); - } - @Nested class 축제_생성 { diff --git a/backend/src/test/java/com/festago/festival/domain/FestivalFilterTest.java b/backend/src/test/java/com/festago/festival/domain/FestivalFilterTest.java new file mode 100644 index 000000000..8c7ea210f --- /dev/null +++ b/backend/src/test/java/com/festago/festival/domain/FestivalFilterTest.java @@ -0,0 +1,54 @@ +package com.festago.festival.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.BadRequestException; +import com.festago.festival.repository.FestivalFilter; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class FestivalFilterTest { + + @Test + void 유효하지_않은_name_이면_예외() { + // given && when && then + assertThatThrownBy(() -> FestivalFilter.from("unvalid")) + .isInstanceOf(BadRequestException.class); + } + + @ValueSource(strings = {"progress", "Progress", "PROGRESS"}) + @ParameterizedTest + void PROGRESS_반환(String value) { + // given && when + FestivalFilter filter = FestivalFilter.from(value); + + // then + assertThat(filter).isEqualTo(FestivalFilter.PROGRESS); + } + + @ValueSource(strings = {"planned", "Planned", "PLANNED"}) + @ParameterizedTest + void PLANNED_반환(String value) { + // given && when + FestivalFilter filter = FestivalFilter.from(value); + + // then + assertThat(filter).isEqualTo(FestivalFilter.PLANNED); + } + + @ValueSource(strings = {"end", "End", "END"}) + @ParameterizedTest + void END_반환(String value) { + // given && when + FestivalFilter filter = FestivalFilter.from(value); + + // then + assertThat(filter).isEqualTo(FestivalFilter.END); + } +} diff --git a/backend/src/test/java/com/festago/festival/repository/FestivalRepositoryTest.java b/backend/src/test/java/com/festago/festival/repository/FestivalRepositoryTest.java new file mode 100644 index 000000000..81e13a050 --- /dev/null +++ b/backend/src/test/java/com/festago/festival/repository/FestivalRepositoryTest.java @@ -0,0 +1,162 @@ +package com.festago.festival.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.festival.domain.Festival; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@DataJpaTest +class FestivalRepositoryTest { + + private static final String CURRENT_FESTIVAL = "현재 축제"; + private static final String PAST_FESTIVAL = "과거 축제"; + private static final String FUTURE_FESTIVAL = "미래 축제"; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + SchoolRepository schoolRepository; + + LocalDate now = LocalDate.parse("2023-06-30"); + + School school; + + @BeforeEach + public void setting() { + this.school = schoolRepository.save(new School("domain", "name")); + } + + private void prepareNotOrderedFestivals() { + festivalRepository.save(new Festival(FUTURE_FESTIVAL, now.plusDays(1L), now.plusDays(3L), school)); + festivalRepository.save(new Festival(CURRENT_FESTIVAL, now, now, school)); + festivalRepository.save(new Festival(PAST_FESTIVAL, now.minusDays(3L), now.minusDays(1L), school)); + } + + @Nested + class 진행_에정_축제_반환 { + + @Test + void 성공() { + // given + FestivalFilter filter = FestivalFilter.PLANNED; + prepareNotOrderedFestivals(); + + // when + List actual = festivalRepository.findAll(filter.getSpecification(now)); + + // then + assertSoftly(softAssertions -> { + assertThat(actual).hasSize(1); + assertThat(actual.get(0)).matches(festival -> festival.getName().equals(FUTURE_FESTIVAL)); + }); + } + + @Test + void 은_시작_시점이_빠른_순서로_반환된다() { + // given + FestivalFilter filter = FestivalFilter.PLANNED; + Festival festival2 = festivalRepository.save( + new Festival("festival2", now.plusDays(2), now.plusDays(10), school)); + Festival festival3 = festivalRepository.save( + new Festival("festival3", now.plusDays(3), now.plusDays(10), school)); + Festival festival1 = festivalRepository.save( + new Festival("festival1", now.plusDays(1), now.plusDays(10), school)); + + // when + List actual = festivalRepository.findAll(filter.getSpecification(now)); + + // then + assertThat(actual).isEqualTo(List.of(festival1, festival2, festival3)); + } + } + + @Nested + class 진행_축제_반환 { + + @Test + void 성공() { + // given + FestivalFilter filter = FestivalFilter.PROGRESS; + prepareNotOrderedFestivals(); + + // when + List actual = festivalRepository.findAll(filter.getSpecification(now)); + + // then + assertSoftly(softAssertions -> { + assertThat(actual).hasSize(1); + assertThat(actual.get(0)).matches(festival -> festival.getName().equals(CURRENT_FESTIVAL)); + }); + } + + @Test + void 은_시작_시점이_빠른_순서로_반환된다() { + // given + FestivalFilter filter = FestivalFilter.PROGRESS; + Festival festival2 = festivalRepository.save( + new Festival("festival2", now.minusDays(2), now.plusDays(10), school)); + Festival festival3 = festivalRepository.save( + new Festival("festival3", now.minusDays(1), now.plusDays(10), school)); + Festival festival1 = festivalRepository.save( + new Festival("festival1", now.minusDays(3), now.plusDays(10), school)); + + // when + List actual = festivalRepository.findAll(filter.getSpecification(now)); + + // then + assertThat(actual).isEqualTo(List.of(festival1, festival2, festival3)); + } + } + + @Nested + class 종료_축제_반환 { + + @Test + void 성공() { + // given + FestivalFilter filter = FestivalFilter.END; + prepareNotOrderedFestivals(); + + // when + List actual = festivalRepository.findAll(filter.getSpecification(now)); + + // then + assertSoftly(softAssertions -> { + assertThat(actual).hasSize(1); + assertThat(actual.get(0)).matches(festival -> festival.getName().equals(PAST_FESTIVAL)); + }); + } + + @Test + void 은_종료_시점이_느린_순서로_반환된다() { + // given + FestivalFilter filter = FestivalFilter.END; + Festival festival2 = festivalRepository.save( + new Festival("festival2", now.minusDays(10), now.minusDays(2), school)); + Festival festival3 = festivalRepository.save( + new Festival("festival3", now.minusDays(10), now.minusDays(1), school)); + Festival festival1 = festivalRepository.save( + new Festival("festival1", now.minusDays(10), now.minusDays(3), school)); + + // when + List actual = festivalRepository.findAll(filter.getSpecification(now)); + + // then + assertThat(actual).isEqualTo(List.of(festival3, festival2, festival1)); + } + } +} diff --git a/backend/src/test/java/com/festago/presentation/FestivalControllerTest.java b/backend/src/test/java/com/festago/presentation/FestivalControllerTest.java index 12f038314..8fef4e0a3 100644 --- a/backend/src/test/java/com/festago/presentation/FestivalControllerTest.java +++ b/backend/src/test/java/com/festago/presentation/FestivalControllerTest.java @@ -1,8 +1,12 @@ package com.festago.presentation; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -12,6 +16,7 @@ import com.festago.festival.dto.FestivalDetailResponse; import com.festago.festival.dto.FestivalResponse; import com.festago.festival.dto.FestivalsResponse; +import com.festago.festival.repository.FestivalFilter; import com.festago.support.CustomWebMvcTest; import java.nio.charset.StandardCharsets; import java.time.LocalDate; @@ -20,6 +25,8 @@ import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; @@ -39,15 +46,18 @@ class FestivalControllerTest { @MockBean FestivalService festivalService; + @Captor + ArgumentCaptor festivalFilterCaptor; + @Test - void 모든_축제를_조회한다() throws Exception { + void 축제를_조회한다() throws Exception { // given FestivalResponse festivalResponse1 = new FestivalResponse(1L, 1L, "테코대학교", LocalDate.now(), LocalDate.now().plusDays(3), "https://image1.png"); FestivalResponse festivalResponse2 = new FestivalResponse(2L, 2L, "우테대학교", LocalDate.now().minusDays(3), LocalDate.now(), "https://image2.png"); FestivalsResponse expected = new FestivalsResponse(List.of(festivalResponse1, festivalResponse2)); - given(festivalService.findAll()) + given(festivalService.findFestivals(any(FestivalFilter.class))) .willReturn(expected); // when & then @@ -59,7 +69,12 @@ class FestivalControllerTest { .getResponse() .getContentAsString(StandardCharsets.UTF_8); FestivalsResponse actual = objectMapper.readValue(content, FestivalsResponse.class); - assertThat(actual).isEqualTo(expected); + assertSoftly(softAssertions -> { + verify(festivalService, times(1)).findFestivals(festivalFilterCaptor.capture()); + assertThat(festivalFilterCaptor.getValue()).isEqualTo(FestivalFilter.PROGRESS); + assertThat(actual).isEqualTo(expected); + } + ); } @Test