-
Notifications
You must be signed in to change notification settings - Fork 44
[VOLUME-10] Spring Batch 를 이용한 주간, 월간 랭킹 구현 #395
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: hyejin0810
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package com.loopers.application.ranking; | ||
|
|
||
| public enum RankingType { | ||
| DAILY, WEEKLY, MONTHLY | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.ProductRankMonthly; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface ProductRankMonthlyJpaRepository extends JpaRepository<ProductRankMonthly, Long> { | ||
|
|
||
| List<ProductRankMonthly> findByYearMonthOrderByRankPosition(String yearMonth); | ||
|
|
||
| long countByYearMonth(String yearMonth); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.ProductRankWeekly; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface ProductRankWeeklyJpaRepository extends JpaRepository<ProductRankWeekly, Long> { | ||
|
|
||
| List<ProductRankWeekly> findByYearWeekOrderByRankPosition(String yearWeek); | ||
|
|
||
| long countByYearWeek(String yearWeek); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| import com.loopers.application.ranking.RankingFacade; | ||
| import com.loopers.application.ranking.RankingInfo; | ||
| import com.loopers.application.ranking.RankingType; | ||
| import com.loopers.interfaces.api.ApiResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
|
|
@@ -25,13 +26,15 @@ public class RankingV1Controller { | |
| @GetMapping | ||
| public ApiResponse<RankingV1Dto.RankingPageResponse> getRankings( | ||
| @RequestParam(required = false) String date, | ||
| @RequestParam(defaultValue = "daily") String type, | ||
| @RequestParam(defaultValue = "20") int size, | ||
| @RequestParam(defaultValue = "1") int page | ||
| ) { | ||
| String rankingDate = (date != null) ? date : LocalDate.now().format(DATE_FORMATTER); | ||
| RankingType rankingType = RankingType.valueOf(type.toUpperCase()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
운영 관점에서 클라이언트가 잘못된 type 파라미터를 전달했을 때 500 에러가 아닌 명확한 400 Bad Request 응답이 필요하다. 수정안- RankingType rankingType = RankingType.valueOf(type.toUpperCase());
+ RankingType rankingType;
+ try {
+ rankingType = RankingType.valueOf(type.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ throw new CoreException(ErrorType.INVALID_INPUT_VALUE, "Invalid ranking type: " + type);
+ }또는 🤖 Prompt for AI Agents |
||
|
|
||
| List<RankingInfo> rankings = rankingFacade.getRankings(rankingDate, page, size); | ||
| long totalElements = rankingFacade.getTotalCount(rankingDate); | ||
| List<RankingInfo> rankings = rankingFacade.getRankings(rankingDate, page, size, rankingType); | ||
| long totalElements = rankingFacade.getTotalCount(rankingDate, rankingType); | ||
|
|
||
| List<RankingV1Dto.RankingItemResponse> content = rankings.stream() | ||
| .map(RankingV1Dto.RankingItemResponse::from) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,10 @@ | ||
| package com.loopers.application.coupon; | ||
|
|
||
| import com.loopers.domain.coupon.CouponTemplate; | ||
| import com.loopers.domain.coupon.CouponIssueRequest; | ||
| import com.loopers.domain.coupon.CouponService; | ||
| import com.loopers.domain.coupon.CouponTemplateRepository; | ||
| import com.loopers.domain.coupon.CouponType; | ||
| import com.loopers.domain.coupon.IssuedCoupon; | ||
| import com.loopers.domain.coupon.IssuedCouponRepository; | ||
| import com.loopers.domain.outbox.OutboxEventPublisher; | ||
| import com.loopers.domain.user.User; | ||
| import com.loopers.domain.user.UserService; | ||
| import com.loopers.support.error.CoreException; | ||
|
|
@@ -17,9 +17,6 @@ | |
| import org.mockito.Mock; | ||
| import org.mockito.junit.jupiter.MockitoExtension; | ||
|
|
||
| import java.time.ZonedDateTime; | ||
| import java.util.Optional; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.junit.jupiter.api.Assertions.assertThrows; | ||
| import static org.mockito.ArgumentMatchers.any; | ||
|
|
@@ -29,106 +26,90 @@ | |
| class CouponFacadeTest { | ||
|
|
||
| @Mock | ||
| private CouponTemplateRepository couponTemplateRepository; | ||
| private CouponService couponService; | ||
|
|
||
| @Mock | ||
| private IssuedCouponRepository issuedCouponRepository; | ||
| private UserService userService; | ||
|
|
||
| @Mock | ||
| private UserService userService; | ||
| private OutboxEventPublisher outboxEventPublisher; | ||
|
|
||
| private CouponFacade couponFacade; | ||
| @Mock | ||
| private CouponTemplateRepository couponTemplateRepository; | ||
|
|
||
| @Mock | ||
| private IssuedCouponRepository issuedCouponRepository; | ||
|
|
||
| private static final ZonedDateTime FUTURE = ZonedDateTime.now().plusDays(30); | ||
| private CouponFacade couponFacade; | ||
|
|
||
| @BeforeEach | ||
| void setUp() { | ||
| couponFacade = new CouponFacade(couponTemplateRepository, issuedCouponRepository, userService); | ||
| couponFacade = new CouponFacade(couponService, userService, outboxEventPublisher, | ||
| couponTemplateRepository, issuedCouponRepository); | ||
| } | ||
|
|
||
| @DisplayName("쿠폰 발급") | ||
| @DisplayName("쿠폰 발급 요청") | ||
| @Nested | ||
| class IssueCoupon { | ||
| class RequestIssue { | ||
|
|
||
| @DisplayName("유효한 쿠폰 템플릿으로 발급 요청하면, 발급된 쿠폰을 반환한다.") | ||
| @DisplayName("유효한 쿠폰으로 발급 요청하면, 발급 요청 정보를 반환한다.") | ||
| @Test | ||
| void returnsIssuedCoupon_whenTemplateIsValid() { | ||
| void returnsCouponIssueInfo_whenCouponIsValid() { | ||
| // Arrange | ||
| String loginId = "user1"; | ||
| String rawPassword = "Test1234!"; | ||
| User user = new User(loginId, "encoded", "홍길동", "19900101", "test@naver.com"); | ||
| CouponTemplate template = new CouponTemplate("10% 할인", CouponType.RATE, 10, null, FUTURE); | ||
| CouponIssueRequest request = new CouponIssueRequest(1L, user.getId()); | ||
|
|
||
| given(userService.authenticate(loginId, rawPassword)).willReturn(user); | ||
| given(couponTemplateRepository.findById(1L)).willReturn(Optional.of(template)); | ||
| given(issuedCouponRepository.existsByUserIdAndCouponTemplateId(any(), any())).willReturn(false); | ||
| given(issuedCouponRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); | ||
| given(couponService.createRequest(1L, user.getId())).willReturn(request); | ||
|
|
||
|
Comment on lines
+62
to
66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 인자 검증이 느슨해 잘못된 사용자/요청 매핑 회귀를 놓칠 수 있다 운영 관점에서 현재 스텁은 권장 수정 예시+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import org.springframework.test.util.ReflectionTestUtils;
User user = new User(loginId, "encoded", "홍길동", "19900101", "test@naver.com");
-CouponIssueRequest request = new CouponIssueRequest(1L, user.getId());
+ReflectionTestUtils.setField(user, "id", 10L);
+Long userId = 10L;
+CouponIssueRequest request = new CouponIssueRequest(1L, userId);
-given(couponService.createRequest(1L, user.getId())).willReturn(request);
+given(couponService.createRequest(eq(1L), eq(userId))).willReturn(request);
...
+verify(couponService).createRequest(1L, userId);
-given(couponService.createRequest(any(), any()))
+given(couponService.createRequest(eq(999L), eq(userId)))
.willThrow(new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다."));As per coding guidelines Also applies to: 83-84, 106-110 🤖 Prompt for AI Agents |
||
| // Act | ||
| CouponInfo.IssuedInfo result = couponFacade.issueCoupon(loginId, rawPassword, 1L); | ||
| CouponIssueInfo result = couponFacade.requestIssue(loginId, rawPassword, 1L); | ||
|
|
||
| // Assert | ||
| assertThat(result).isNotNull(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 성공 케이스 단언이 운영 관점에서 As per coding guidelines Also applies to: 115-115 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| @DisplayName("존재하지 않는 쿠폰 템플릿으로 발급 요청하면, NOT_FOUND 예외가 발생한다.") | ||
| @DisplayName("존재하지 않는 쿠폰으로 발급 요청하면, NOT_FOUND 예외가 발생한다.") | ||
| @Test | ||
| void throwsNotFound_whenTemplateDoesNotExist() { | ||
| void throwsNotFound_whenCouponDoesNotExist() { | ||
| // Arrange | ||
| String loginId = "user1"; | ||
| String rawPassword = "Test1234!"; | ||
| User user = new User(loginId, "encoded", "홍길동", "19900101", "test@naver.com"); | ||
|
|
||
| given(userService.authenticate(loginId, rawPassword)).willReturn(user); | ||
| given(couponTemplateRepository.findById(999L)).willReturn(Optional.empty()); | ||
| given(couponService.createRequest(any(), any())) | ||
| .willThrow(new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); | ||
|
|
||
| // Act | ||
| CoreException exception = assertThrows(CoreException.class, | ||
| () -> couponFacade.issueCoupon(loginId, rawPassword, 999L)); | ||
| () -> couponFacade.requestIssue(loginId, rawPassword, 999L)); | ||
|
|
||
| // Assert | ||
| assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); | ||
| } | ||
|
|
||
| @DisplayName("이미 발급받은 쿠폰을 다시 발급 요청하면, CONFLICT 예외가 발생한다.") | ||
| @Test | ||
| void throwsConflict_whenCouponAlreadyIssued() { | ||
| // Arrange | ||
| String loginId = "user1"; | ||
| String rawPassword = "Test1234!"; | ||
| User user = new User(loginId, "encoded", "홍길동", "19900101", "test@naver.com"); | ||
| CouponTemplate template = new CouponTemplate("10% 할인", CouponType.RATE, 10, null, FUTURE); | ||
|
|
||
| given(userService.authenticate(loginId, rawPassword)).willReturn(user); | ||
| given(couponTemplateRepository.findById(1L)).willReturn(Optional.of(template)); | ||
| given(issuedCouponRepository.existsByUserIdAndCouponTemplateId(any(), any())).willReturn(true); | ||
|
|
||
| // Act | ||
| CoreException exception = assertThrows(CoreException.class, | ||
| () -> couponFacade.issueCoupon(loginId, rawPassword, 1L)); | ||
|
|
||
| // Assert | ||
| assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); | ||
| } | ||
| } | ||
|
|
||
| @DisplayName("내 쿠폰 목록 조회") | ||
| @DisplayName("쿠폰 발급 결과 조회") | ||
| @Nested | ||
| class GetMyCoupons { | ||
| class GetIssueResult { | ||
|
|
||
| @DisplayName("인증된 유저의 쿠폰 목록을 반환한다.") | ||
| @DisplayName("인증된 유저가 발급 요청 결과를 조회하면 결과를 반환한다.") | ||
| @Test | ||
| void returnsMyCoupons_whenAuthenticated() { | ||
| void returnsIssueResult_whenAuthenticated() { | ||
| // Arrange | ||
| String loginId = "user1"; | ||
| String rawPassword = "Test1234!"; | ||
| User user = new User(loginId, "encoded", "홍길동", "19900101", "test@naver.com"); | ||
| CouponIssueRequest request = new CouponIssueRequest(1L, user.getId()); | ||
|
|
||
| given(userService.authenticate(loginId, rawPassword)).willReturn(user); | ||
| given(issuedCouponRepository.findAllByUserId(any())).willReturn(java.util.List.of()); | ||
| given(couponService.getRequest(1L)).willReturn(request); | ||
|
|
||
| // Act | ||
| var result = couponFacade.getMyCoupons(loginId, rawPassword); | ||
| CouponIssueInfo result = couponFacade.getIssueResult(loginId, rawPassword, 1L); | ||
|
|
||
| // Assert | ||
| assertThat(result).isNotNull(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
잘못된 date 형식 입력 시
DateTimeParseException이 그대로 전파된다.date파라미터가yyyyMMdd형식이 아닌 경우LocalDate.parse에서DateTimeParseException이 발생하여 500 에러로 응답된다. Controller 또는 Facade에서 적절한 예외 변환(400 Bad Request)이 필요하다.🛡️ 예외 처리 추가 예시
private String toYearWeek(String date) { + try { LocalDate localDate = LocalDate.parse(date, DATE_FORMATTER); WeekFields weekFields = WeekFields.ISO; int week = localDate.get(weekFields.weekOfWeekBasedYear()); int year = localDate.get(weekFields.weekBasedYear()); return year + "-W" + String.format("%02d", week); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.INVALID_DATE_FORMAT, "date 형식이 올바르지 않습니다: " + date); + } }🤖 Prompt for AI Agents