diff --git a/sql/schema.sql b/sql/schema.sql index c9f392fd..2f3f0e19 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -106,9 +106,14 @@ create table product_like ( create table promotion ( id bigint not null auto_increment, + name varchar(50) not null unique, + image_url varchar(255) not null, + landing_url varchar(255), + priority integer, + start_at datetime, + end_at datetime, created_at datetime, modified_at datetime, - image_url varchar(255), primary key (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/docs/asciidoc/api-doc.adoc b/src/docs/asciidoc/api-doc.adoc index 42dbc188..c1c37c22 100644 --- a/src/docs/asciidoc/api-doc.adoc +++ b/src/docs/asciidoc/api-doc.adoc @@ -460,3 +460,12 @@ include::{snippets}/notice-controller-test/respond_200_when_read_notice_successf | `404 NOT FOUND` | `NOT_FOUND_NOTICE` | 해당하는 공지사항이 없는 경우 |=== + +== 7. 프로모션 +=== 7-1. 프로모션 목록 조회 +==== Sample Request +include::{snippets}/promotion-controller-test/respond_200_when_read_promotion_list_successfully/http-request.adoc[] +==== Response Fields +include::{snippets}/promotion-controller-test/respond_200_when_read_promotion_list_successfully/response-fields.adoc[] +==== Sample Response +include::{snippets}/promotion-controller-test/respond_200_when_read_promotion_list_successfully/http-response.adoc[] diff --git a/src/main/java/com/cvsgo/config/WebConfig.java b/src/main/java/com/cvsgo/config/WebConfig.java index 92ab677d..370a9581 100644 --- a/src/main/java/com/cvsgo/config/WebConfig.java +++ b/src/main/java/com/cvsgo/config/WebConfig.java @@ -30,7 +30,7 @@ public void addInterceptors(InterceptorRegistry registry) { .addPathPatterns("/**") .excludePathPatterns("/", "/docs/**", "/*.ico", "/api/auth/login", "/api/users", "/api/tags", "/api/users/emails/*/exists", "/api/users/nicknames/*/exists", - "/api/products", "/api/products/*", "/api/products/*/tags", "/api/users/*/reviews", - "/error"); + "/api/promotions", "/api/products", "/api/products/*", "/api/products/*/tags", + "/api/users/*/reviews","/error"); } } diff --git a/src/main/java/com/cvsgo/controller/PromotionController.java b/src/main/java/com/cvsgo/controller/PromotionController.java new file mode 100644 index 00000000..b032a7a6 --- /dev/null +++ b/src/main/java/com/cvsgo/controller/PromotionController.java @@ -0,0 +1,24 @@ +package com.cvsgo.controller; + +import com.cvsgo.dto.SuccessResponse; +import com.cvsgo.dto.promotion.ReadPromotionResponseDto; +import com.cvsgo.service.PromotionService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/promotions") +public class PromotionController { + + private final PromotionService promotionService; + + @GetMapping + private SuccessResponse> readPromotionList() { + return SuccessResponse.from(promotionService.readPromotionList()); + } + +} diff --git a/src/main/java/com/cvsgo/dto/promotion/ReadPromotionResponseDto.java b/src/main/java/com/cvsgo/dto/promotion/ReadPromotionResponseDto.java new file mode 100644 index 00000000..5baf99db --- /dev/null +++ b/src/main/java/com/cvsgo/dto/promotion/ReadPromotionResponseDto.java @@ -0,0 +1,24 @@ +package com.cvsgo.dto.promotion; + +import com.cvsgo.entity.Promotion; +import lombok.Getter; + +@Getter +public class ReadPromotionResponseDto { + + private final Long id; + + private final String imageUrl; + + private final String landingUrl; + + public ReadPromotionResponseDto(Promotion promotion) { + this.id = promotion.getId(); + this.imageUrl = promotion.getImageUrl(); + this.landingUrl = promotion.getLandingUrl(); + } + + public static ReadPromotionResponseDto from(Promotion promotion) { + return new ReadPromotionResponseDto(promotion); + } +} diff --git a/src/main/java/com/cvsgo/entity/Promotion.java b/src/main/java/com/cvsgo/entity/Promotion.java index ac274096..cccc0aba 100644 --- a/src/main/java/com/cvsgo/entity/Promotion.java +++ b/src/main/java/com/cvsgo/entity/Promotion.java @@ -1,9 +1,12 @@ package com.cvsgo.entity; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -18,11 +21,30 @@ public class Promotion extends BaseTimeEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @NotNull + @Column(unique = true) + private String name; + + @NotNull private String imageUrl; + private String landingUrl; + + private Integer priority; + + private LocalDateTime startAt; + + private LocalDateTime endAt; + @Builder - public Promotion(Long id, String imageUrl) { + public Promotion(Long id, String name, String imageUrl, String landingUrl, Integer priority, + LocalDateTime startAt, LocalDateTime endAt) { this.id = id; + this.name = name; this.imageUrl = imageUrl; + this.landingUrl = landingUrl; + this.priority = priority; + this.startAt = startAt; + this.endAt = endAt; } } diff --git a/src/main/java/com/cvsgo/repository/PromotionCustomRepository.java b/src/main/java/com/cvsgo/repository/PromotionCustomRepository.java new file mode 100644 index 00000000..0be29989 --- /dev/null +++ b/src/main/java/com/cvsgo/repository/PromotionCustomRepository.java @@ -0,0 +1,11 @@ +package com.cvsgo.repository; + +import com.cvsgo.entity.Promotion; +import java.time.LocalDateTime; +import java.util.List; + +public interface PromotionCustomRepository { + + List findActivePromotions(LocalDateTime now); + +} diff --git a/src/main/java/com/cvsgo/repository/PromotionCustomRepositoryImpl.java b/src/main/java/com/cvsgo/repository/PromotionCustomRepositoryImpl.java new file mode 100644 index 00000000..52251177 --- /dev/null +++ b/src/main/java/com/cvsgo/repository/PromotionCustomRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.cvsgo.repository; + +import static com.cvsgo.entity.QPromotion.promotion; + +import com.cvsgo.entity.Promotion; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class PromotionCustomRepositoryImpl implements PromotionCustomRepository { + + private final JPAQueryFactory queryFactory; + + public List findActivePromotions(LocalDateTime now) { + return queryFactory.selectFrom(promotion) + .where(promotion.startAt.loe(now).and(promotion.endAt.goe(now))) + .orderBy(promotion.priority.asc()) + .fetch(); + } + +} diff --git a/src/main/java/com/cvsgo/repository/PromotionRepository.java b/src/main/java/com/cvsgo/repository/PromotionRepository.java index 32c9caae..efb852de 100644 --- a/src/main/java/com/cvsgo/repository/PromotionRepository.java +++ b/src/main/java/com/cvsgo/repository/PromotionRepository.java @@ -1,8 +1,13 @@ package com.cvsgo.repository; import com.cvsgo.entity.Promotion; +import java.time.LocalDateTime; +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; -public interface PromotionRepository extends JpaRepository { +public interface PromotionRepository extends JpaRepository, PromotionCustomRepository { } diff --git a/src/main/java/com/cvsgo/service/PromotionService.java b/src/main/java/com/cvsgo/service/PromotionService.java new file mode 100644 index 00000000..41297e84 --- /dev/null +++ b/src/main/java/com/cvsgo/service/PromotionService.java @@ -0,0 +1,30 @@ +package com.cvsgo.service; + +import com.cvsgo.dto.promotion.ReadPromotionResponseDto; +import com.cvsgo.repository.PromotionRepository; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PromotionService { + + private final PromotionRepository promotionRepository; + + /** + * 현재 로컬 날짜 및 시간 기준 활성된 프로모션 목록을 우선순위 순으로 조회한다. + * + * @return 프로모션 목록 + */ + @Transactional(readOnly = true) + public List readPromotionList() { + List promotionResponseDtos = promotionRepository.findActivePromotions(LocalDateTime.now()).stream() + .map(ReadPromotionResponseDto::from).toList(); + return promotionResponseDtos; + } + +} diff --git a/src/test/java/com/cvsgo/controller/PromotionControllerTest.java b/src/test/java/com/cvsgo/controller/PromotionControllerTest.java new file mode 100644 index 00000000..4a9e6f9a --- /dev/null +++ b/src/test/java/com/cvsgo/controller/PromotionControllerTest.java @@ -0,0 +1,97 @@ +package com.cvsgo.controller; + +import static com.cvsgo.ApiDocumentUtils.documentIdentifier; +import static com.cvsgo.ApiDocumentUtils.getDocumentRequest; +import static com.cvsgo.ApiDocumentUtils.getDocumentResponse; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.relaxedResponseFields; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.SharedHttpSessionConfigurer.sharedHttpSession; + +import com.cvsgo.argumentresolver.LoginUserArgumentResolver; +import com.cvsgo.config.WebConfig; +import com.cvsgo.dto.promotion.ReadPromotionResponseDto; +import com.cvsgo.entity.Promotion; +import com.cvsgo.interceptor.AuthInterceptor; +import com.cvsgo.service.PromotionService; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.filter.CharacterEncodingFilter; + +@ExtendWith(RestDocumentationExtension.class) +@WebMvcTest(PromotionController.class) +class PromotionControllerTest { + + @MockBean + LoginUserArgumentResolver loginUserArgumentResolver; + + @MockBean + WebConfig webConfig; + + @MockBean + AuthInterceptor authInterceptor; + + @MockBean + private PromotionService promotionService; + + private MockMvc mockMvc; + + @BeforeEach + void setup(WebApplicationContext webApplicationContext, + RestDocumentationContextProvider restDocumentation) { + this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(documentationConfiguration(restDocumentation)) + .apply(sharedHttpSession()) + .addFilters(new CharacterEncodingFilter("UTF-8", true)) + .build(); + } + + @Test + @DisplayName("프로모션 목록을 정상적으로 조회하면 HTTP 200을 응답한다") + void respond_200_when_read_promotion_list_successfully() throws Exception { + List responseDto = List.of(new ReadPromotionResponseDto(promotion1), new ReadPromotionResponseDto(promotion2)); + given(promotionService.readPromotionList()).willReturn(responseDto); + + mockMvc.perform(get("/api/promotions").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(print()) + .andDo(document(documentIdentifier, + getDocumentRequest(), + getDocumentResponse(), + relaxedResponseFields( + fieldWithPath("data.[].id").type(JsonFieldType.NUMBER).description("프로모션 ID"), + fieldWithPath("data.[].imageUrl").type(JsonFieldType.STRING).description("프로모션 이미지 url"), + fieldWithPath("data.[].landingUrl").type(JsonFieldType.STRING).description("프로모션 랜딩 url") + ) + )); + } + + Promotion promotion1 = Promotion.builder() + .id(1L) + .imageUrl("imageUrl1") + .landingUrl("landindUrl1") + .build(); + + Promotion promotion2 = Promotion.builder() + .id(2L) + .imageUrl("imageUrl2") + .landingUrl("landindUrl2") + .build(); +} diff --git a/src/test/java/com/cvsgo/repository/PromotionRepositoryTest.java b/src/test/java/com/cvsgo/repository/PromotionRepositoryTest.java new file mode 100644 index 00000000..f4e0f5ec --- /dev/null +++ b/src/test/java/com/cvsgo/repository/PromotionRepositoryTest.java @@ -0,0 +1,55 @@ +package com.cvsgo.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.cvsgo.config.TestConfig; +import com.cvsgo.entity.Promotion; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@Import(TestConfig.class) +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class PromotionRepositoryTest { + + @Autowired + PromotionRepository promotionRepository; + + @BeforeEach + void initData() { + promotion1 = Promotion.builder().id(1L).name("프로모션1").imageUrl("imageUrl1").landingUrl("landindUrl1") + .priority(3).startAt(LocalDateTime.now().minusDays(3)).endAt(LocalDateTime.now().minusDays(1)).build(); + promotion2 = Promotion.builder().id(2L).name("프로모션2").imageUrl("imageUrl2").landingUrl("landindUrl2") + .priority(2).startAt(LocalDateTime.now().minusDays(1)).endAt(LocalDateTime.now().plusDays(1)).build(); + promotion3 = Promotion.builder().id(3L).name("프로모션3").imageUrl("imageUrl3").landingUrl("landindUrl3") + .priority(1).startAt(LocalDateTime.now().minusDays(5)).endAt(LocalDateTime.now().plusDays(6)).build(); + promotion4 = Promotion.builder().id(2L).name("프로모션4").imageUrl("imageUrl2").landingUrl("landindUrl2") + .priority(1).startAt(LocalDateTime.now().plusDays(1)).endAt(LocalDateTime.now().plusDays(3)).build(); + promotionRepository.saveAll(List.of(promotion1, promotion2, promotion3, promotion4)); + } + + @Test + @DisplayName("활성된 프로모션을 조회한다") + void find_active_promotions() { + // when + LocalDateTime now = LocalDateTime.now(); + List foundPromotions = promotionRepository.findActivePromotions(now); + + // then + assertThat(foundPromotions).hasSize(2); + assertThat(foundPromotions.get(0).getPriority()).isEqualTo(1); + assertThat(foundPromotions.get(1).getPriority()).isEqualTo(2); + } + + private Promotion promotion1; + private Promotion promotion2; + private Promotion promotion3; + private Promotion promotion4; +} diff --git a/src/test/java/com/cvsgo/service/PromotionServiceTest.java b/src/test/java/com/cvsgo/service/PromotionServiceTest.java new file mode 100644 index 00000000..9c194170 --- /dev/null +++ b/src/test/java/com/cvsgo/service/PromotionServiceTest.java @@ -0,0 +1,57 @@ +package com.cvsgo.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +import com.cvsgo.dto.promotion.ReadPromotionResponseDto; +import com.cvsgo.entity.Promotion; +import com.cvsgo.repository.PromotionRepository; +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; + +@ExtendWith(MockitoExtension.class) +class PromotionServiceTest { + + @Mock + private PromotionRepository promotionRepository; + + @InjectMocks + PromotionService promotionService; + + @Test + @DisplayName("프로모션 목록을 정상적으로 조회한다") + void succeed_to_read_promotion_list() { + given(promotionRepository.findActivePromotions(any())).willReturn(getPromotionList()); + + List result = promotionService.readPromotionList(); + + assertEquals(2, result.size()); + + then(promotionRepository).should(times(1)).findActivePromotions(any(LocalDateTime.class)); + } + + Promotion promotion1 = Promotion.builder() + .id(1L) + .imageUrl("imageUrl1") + .landingUrl("landindUrl1") + .build(); + + Promotion promotion2 = Promotion.builder() + .id(2L) + .imageUrl("imageUrl2") + .landingUrl("landindUrl2") + .build(); + + private List getPromotionList() { + return List.of(promotion1, promotion2); + } +}