diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java index d52f2e49b..67cb7015c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -1,39 +1,25 @@ package com.loopers.application.product; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import com.loopers.domain.common.vo.Money; import com.loopers.domain.product.vo.Stock; import lombok.Builder; +import lombok.Getter; +@Getter @Builder -@JsonDeserialize(builder = ProductDetailInfo.ProductDetailInfoBuilder.class) public class ProductDetailInfo { private final Long id; private final String name; private final String description; + private final Long brandId; private final String brandName; private final String brandDescription; private final Money price; private final Stock stock; private final int likeCount; - private final boolean isLikedByMember; - - public Long getId() { return id; } - public String getName() { return name; } - public String getDescription() { return description; } - public String getBrandName() { return brandName; } - public String getBrandDescription() { return brandDescription; } - public Money getPrice() { return price; } - public Stock getStock() { return stock; } - public int getLikeCount() { return likeCount; } - @JsonProperty("likedByMember") - public boolean isLikedByMember() { return isLikedByMember; } - - @JsonPOJOBuilder(withPrefix = "") - public static class ProductDetailInfoBuilder { - } + private final boolean isLikedByMember; + private final Integer ranking; // 순위 (1-based), 순위권 밖이면 null } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index b18658643..d9267962b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -2,13 +2,12 @@ import com.loopers.application.event.product.ProductViewedEvent; import com.loopers.domain.like.service.LikeReadService; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.repository.ProductRepository; import com.loopers.domain.product.service.ProductReadService; import com.loopers.domain.product.command.ProductSearchFilter; import com.loopers.domain.product.enums.ProductSortCondition; import com.loopers.infrastructure.cache.ProductDetailCache; import com.loopers.infrastructure.cache.ProductListCache; +import com.loopers.infrastructure.cache.ProductRankingCache; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; @@ -29,7 +28,7 @@ public class ProductFacade { private final LikeReadService likeReadService; private final ProductDetailCache productDetailCache; private final ProductListCache productListCache; - private final ProductRepository productRepository; + private final ProductRankingCache productRankingCache; private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) @@ -100,33 +99,33 @@ public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) { return result; }); - // 2. Product 엔티티 조회 (brandId 획득용) - Product product = productRepository.findById(productId) - .orElseThrow(() -> new com.loopers.support.error.CoreException( - com.loopers.support.error.ErrorType.NOT_FOUND, - "상품을 찾을 수 없습니다.")); - - // 3. isLikedByMember 동적 계산 + // 2. isLikedByMember 동적 계산 boolean isLiked = memberIdOrNull != null && likeReadService.isLikedBy(memberIdOrNull, productId); - // 4. isLikedByMember 필드만 교체해서 반환 + // 3. 순위 조회 (실시간) + Integer ranking = productRankingCache.getRank(productId); + + // 4. 동적 필드(isLikedByMember, ranking)를 교체해서 반환 ProductDetailInfo result = ProductDetailInfo.builder() .id(cachedInfo.getId()) .name(cachedInfo.getName()) .description(cachedInfo.getDescription()) + .brandId(cachedInfo.getBrandId()) .brandName(cachedInfo.getBrandName()) .brandDescription(cachedInfo.getBrandDescription()) .price(cachedInfo.getPrice()) .stock(cachedInfo.getStock()) .likeCount(cachedInfo.getLikeCount()) - .isLikedByMember(isLiked) // ⭐ 동적 계산 + .isLikedByMember(isLiked) + .ranking(ranking) .build(); // 5. ProductViewedEvent 발행 (조회수 집계) + // brandId는 캐시된 정보에서 가져옴 (불필요한 DB 조회 제거) eventPublisher.publishEvent(new ProductViewedEvent( memberIdOrNull, // 비로그인 사용자는 null productId, - product.getBrandId(), + cachedInfo.getBrandId(), LocalDateTime.now() )); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSummaryInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSummaryInfo.java index a7d279eee..909c04371 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSummaryInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSummaryInfo.java @@ -1,13 +1,12 @@ package com.loopers.application.product; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import com.loopers.domain.common.vo.Money; import lombok.Builder; +import lombok.Getter; +@Getter @Builder -@JsonDeserialize(builder = ProductSummaryInfo.ProductSummaryInfoBuilder.class) public class ProductSummaryInfo { private final Long id; @@ -15,18 +14,6 @@ public class ProductSummaryInfo { private final String brandName; private final Money price; private final int likeCount; - private final boolean isLikedByMember; - - public Long getId() { return id; } - public String getName() { return name; } - public String getBrandName() { return brandName; } - public Money getPrice() { return price; } - public int getLikeCount() { return likeCount; } - @JsonProperty("likedByMember") - public boolean isLikedByMember() { return isLikedByMember; } - - @JsonPOJOBuilder(withPrefix = "") - public static class ProductSummaryInfoBuilder { - } + private final boolean isLikedByMember; } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java new file mode 100644 index 000000000..c8dfc8226 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,111 @@ +package com.loopers.application.ranking; + +import com.loopers.application.ranking.RankingInfo.RankingItemInfo; +import com.loopers.application.ranking.RankingInfo.RankingPageInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.repository.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.repository.ProductRepository; +import com.loopers.infrastructure.cache.ProductRankingCache; +import com.loopers.infrastructure.cache.ProductRankingCache.RankingEntry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Component +@Transactional(readOnly = true) +public class RankingFacade { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final ProductRankingCache productRankingCache; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + /** @param page 0-based */ + public RankingPageInfo getRankings(String date, int page, int size) { + // 날짜 검증 및 기본값 처리 + String targetDate = validateAndNormalizeDate(date); + + // 1. ZSET에서 랭킹 조회 + List rankingEntries = productRankingCache.getTopRankings(targetDate, page, size); + + if (rankingEntries.isEmpty()) { + return RankingPageInfo.of(Collections.emptyList(), targetDate, page, size, 0); + } + + // 2. 상품 ID 목록 추출 + List productIds = rankingEntries.stream() + .map(RankingEntry::productId) + .toList(); + + // 3. 상품 정보 조회 + List products = productRepository.findByIdIn(productIds); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + // 4. 브랜드 정보 조회 (N+1 방지) + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + List brands = brandRepository.findByIdIn(brandIds); + Map brandMap = brands.stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + // 5. 응답 생성 + List rankings = rankingEntries.stream() + .map(entry -> { + Product product = productMap.get(entry.productId()); + if (product == null) { + log.warn("[Ranking] Product not found - productId: {}", entry.productId()); + return null; + } + Brand brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : "Unknown"; + + return new RankingItemInfo( + entry.rank(), + product.getId(), + product.getName(), + brandName, + product.getPrice(), + product.getLikeCount(), + entry.score() + ); + }) + .filter(item -> item != null) + .toList(); + + // 6. 전체 개수 조회 + long totalCount = productRankingCache.getTotalCount(targetDate); + + return RankingPageInfo.of(rankings, targetDate, page, size, totalCount); + } + + /** @throws IllegalArgumentException 유효하지 않은 날짜 형식 */ + private String validateAndNormalizeDate(String date) { + if (date == null || date.isBlank()) { + return productRankingCache.getTodayDate(); + } + + try { + LocalDate.parse(date, DATE_FORMATTER); + return date; + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Invalid date format. Expected yyyyMMdd, got: " + date); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java new file mode 100644 index 000000000..1a77aff81 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java @@ -0,0 +1,40 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.common.vo.Money; + +import java.util.List; + +public class RankingInfo { + + public record RankingPageInfo( + List rankings, + String date, + int page, + int size, + long totalCount, + int totalPages, + boolean hasNext + ) { + public static RankingPageInfo of( + List rankings, + String date, + int page, + int size, + long totalCount + ) { + int totalPages = size > 0 ? (int) Math.ceil((double) totalCount / size) : 0; + boolean hasNext = page < totalPages - 1; + return new RankingPageInfo(rankings, date, page, size, totalCount, totalPages, hasNext); + } + } + + public record RankingItemInfo( + int rank, + Long productId, + String productName, + String brandName, + Money price, + int likeCount, + Double score + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java index d2223f46d..3d89adcf0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java @@ -51,12 +51,14 @@ public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) { .id(product.getId()) .name(product.getName()) .description(product.getDescription()) + .brandId(product.getBrandId()) .brandName(brand.getName()) .brandDescription(brand.getDescription()) .price(product.getPrice()) .stock(product.getStock()) .likeCount(product.getLikeCount()) .isLikedByMember(isLikedByMember) + .ranking(null) // 캐시 저장용, Facade에서 실시간 조회로 교체 .build(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java index 232cde1ce..d558d1682 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java @@ -50,4 +50,10 @@ public static String productLikeCountKey(Long productId) { public static String productLikeCountKeyPattern() { return String.format("product:like:count:%s:*", VERSION); } + + // Daily ranking ZSET key + public static String dailyRankingKey(String date) { + return String.format("ranking:all:%s:%s", VERSION, date); + // 예: "ranking:all:v1:20251223" + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java new file mode 100644 index 000000000..963131d99 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java @@ -0,0 +1,123 @@ +package com.loopers.infrastructure.cache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * 상품 랭킹 ZSET 캐시 (조회용 - commerce-api) + * Key: ranking:all:v1:{yyyyMMdd} + * Member: productId + * Score: 가중치 적용된 점수 + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class ProductRankingCache { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); + + private final RedisTemplate cacheRedisTemplate; + + /** @return 순위 (1-based), 순위권 밖이면 null */ + public Integer getRank(Long productId) { + return getRank(productId, getTodayDate()); + } + + /** @return 순위 (1-based), 순위권 밖이면 null */ + public Integer getRank(Long productId, String date) { + try { + String key = CacheKeyGenerator.dailyRankingKey(date); + Long rank = cacheRedisTemplate.opsForZSet().reverseRank(key, productId.toString()); + + if (rank == null) { + log.debug("[Ranking] Product not in ranking - productId: {}, date: {}", productId, date); + return null; + } + + // 0-based → 1-based 변환 + int ranking = rank.intValue() + 1; + log.debug("[Ranking] Rank retrieved - productId: {}, date: {}, rank: {}", productId, date, ranking); + return ranking; + } catch (Exception e) { + log.warn("[Ranking] Failed to get rank - productId: {}, date: {}, error: {}", + productId, date, e.getMessage()); + return null; + } + } + + public Double getScore(Long productId) { + return getScore(productId, getTodayDate()); + } + + public Double getScore(Long productId, String date) { + try { + String key = CacheKeyGenerator.dailyRankingKey(date); + return cacheRedisTemplate.opsForZSet().score(key, productId.toString()); + } catch (Exception e) { + log.warn("[Ranking] Failed to get score - productId: {}, date: {}, error: {}", + productId, date, e.getMessage()); + return null; + } + } + + public String getTodayDate() { + return LocalDate.now(ZONE_ID).format(DATE_FORMATTER); + } + + /** @param page 0-based */ + public List getTopRankings(String date, int page, int size) { + try { + String key = CacheKeyGenerator.dailyRankingKey(date); + long start = (long) page * size; + long end = start + size - 1; + + Set> results = + cacheRedisTemplate.opsForZSet().reverseRangeWithScores(key, start, end); + + if (results == null || results.isEmpty()) { + log.debug("[Ranking] No rankings found - date: {}, page: {}, size: {}", date, page, size); + return Collections.emptyList(); + } + + int rank = page * size + 1; // 시작 순위 (1-based) + List entries = new java.util.ArrayList<>(); + + for (ZSetOperations.TypedTuple tuple : results) { + Long productId = Long.parseLong(tuple.getValue().toString()); + Double score = tuple.getScore(); + entries.add(new RankingEntry(rank++, productId, score)); + } + + log.debug("[Ranking] Retrieved {} rankings - date: {}, page: {}", entries.size(), date, page); + return entries; + } catch (Exception e) { + log.warn("[Ranking] Failed to get top rankings - date: {}, page: {}, error: {}", + date, page, e.getMessage()); + return Collections.emptyList(); + } + } + + public long getTotalCount(String date) { + try { + String key = CacheKeyGenerator.dailyRankingKey(date); + Long count = cacheRedisTemplate.opsForZSet().zCard(key); + return count != null ? count : 0; + } catch (Exception e) { + log.warn("[Ranking] Failed to get total count - date: {}, error: {}", date, e.getMessage()); + return 0; + } + } + + public record RankingEntry(int rank, Long productId, Double score) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 377a3d28c..52b01c5bb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -18,6 +18,7 @@ import org.springframework.web.server.ServerWebInputException; import org.springframework.web.servlet.resource.NoResourceFoundException; +import jakarta.validation.ConstraintViolationException; import java.util.Arrays; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -119,6 +120,26 @@ public ResponseEntity> handleBadRequest(ServerWebInputException e } } + @ExceptionHandler + public ResponseEntity> handleBadRequest(IllegalArgumentException e) { + log.warn("IllegalArgumentException : {}", e.getMessage()); + return failureResponse(ErrorType.BAD_REQUEST, e.getMessage()); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(ConstraintViolationException e) { + String message = e.getConstraintViolations().stream() + .map(v -> { + String path = v.getPropertyPath().toString(); + // "getRankings.page" -> "page" + String field = path.contains(".") ? path.substring(path.lastIndexOf('.') + 1) : path; + return String.format("'%s': %s", field, v.getMessage()); + }) + .collect(Collectors.joining(", ")); + log.warn("ConstraintViolationException : {}", message); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleNotFound(NoResourceFoundException e) { return failureResponse(ErrorType.NOT_FOUND, null); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index 1d31dfccf..124fd8062 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -39,7 +39,8 @@ public record ProductDetailResponse( BigDecimal price, int stockQuantity, int likeCount, - boolean isLikedByMember + boolean isLikedByMember, + Integer ranking // 순위 (1-based), 순위권 밖이면 null ) { public static ProductDetailResponse from(ProductDetailInfo info) { return new ProductDetailResponse( @@ -51,7 +52,8 @@ public static ProductDetailResponse from(ProductDetailInfo info) { info.getPrice().getAmount(), info.getStock().getQuantity(), info.getLikeCount(), - info.isLikedByMember() + info.isLikedByMember(), + info.getRanking() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java new file mode 100644 index 000000000..1e9b98610 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingFacade; +import com.loopers.application.ranking.RankingInfo.RankingPageInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.ranking.RankingV1Dto.RankingPageResponse; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1") +public class RankingV1Controller { + + private final RankingFacade rankingFacade; + + /** + * 랭킹 페이지 조회 + * GET /api/v1/rankings?date=yyyyMMdd&size=20&page=1 + * + * @param page 페이지 번호 (1-based, 기본값 1) + */ + @GetMapping("/rankings") + public ApiResponse getRankings( + @RequestParam(value = "date", required = false) String date, + @RequestParam(value = "page", defaultValue = "1") @Min(1) int page, + @RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(100) int size + ) { + // API는 1-based, 내부는 0-based로 변환 + int zeroBasedPage = Math.max(0, page - 1); + RankingPageInfo info = rankingFacade.getRankings(date, zeroBasedPage, size); + RankingPageResponse response = RankingPageResponse.from(info); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java new file mode 100644 index 000000000..50ac99c92 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -0,0 +1,57 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingInfo.RankingItemInfo; +import com.loopers.application.ranking.RankingInfo.RankingPageInfo; + +import java.math.BigDecimal; +import java.util.List; + +public class RankingV1Dto { + + public record RankingPageResponse( + List rankings, + String date, + int page, // 1-based page number for API response + int size, + long totalCount, + int totalPages, + boolean hasNext + ) { + public static RankingPageResponse from(RankingPageInfo info) { + List rankings = info.rankings().stream() + .map(RankingItemResponse::from) + .toList(); + return new RankingPageResponse( + rankings, + info.date(), + info.page() + 1, // 0-based → 1-based 변환 + info.size(), + info.totalCount(), + info.totalPages(), + info.hasNext() + ); + } + } + + public record RankingItemResponse( + int rank, + Long productId, + String productName, + String brandName, + BigDecimal price, + int likeCount, + Double score + ) { + public static RankingItemResponse from(RankingItemInfo info) { + return new RankingItemResponse( + info.rank(), + info.productId(), + info.productName(), + info.brandName(), + info.price().getAmount(), + info.likeCount(), + info.score() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java new file mode 100644 index 000000000..7c9ad32fd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,211 @@ +package com.loopers.application.product; + +import com.loopers.application.event.product.ProductViewedEvent; +import com.loopers.domain.common.vo.Money; +import com.loopers.domain.like.service.LikeReadService; +import com.loopers.domain.product.service.ProductReadService; +import com.loopers.domain.product.vo.Stock; +import com.loopers.infrastructure.cache.ProductDetailCache; +import com.loopers.infrastructure.cache.ProductListCache; +import com.loopers.infrastructure.cache.ProductRankingCache; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ProductFacadeTest { + + @Mock + private ProductReadService productReadService; + + @Mock + private LikeReadService likeReadService; + + @Mock + private ProductDetailCache productDetailCache; + + @Mock + private ProductListCache productListCache; + + @Mock + private ProductRankingCache productRankingCache; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private ProductFacade productFacade; + + private static final Long PRODUCT_ID = 100L; + private static final Long MEMBER_ID = 1L; + private static final Long BRAND_ID = 10L; + + private ProductDetailInfo createCachedProductDetailInfo() { + return ProductDetailInfo.builder() + .id(PRODUCT_ID) + .name("테스트 상품") + .description("테스트 설명") + .brandId(BRAND_ID) + .brandName("테스트 브랜드") + .brandDescription("브랜드 설명") + .price(Money.of(10000)) + .stock(Stock.of(100)) + .likeCount(50) + .isLikedByMember(false) + .ranking(null) + .build(); + } + + @DisplayName("상품 상세 조회 (getProductDetail)") + @Nested + class GetProductDetail { + + @DisplayName("랭킹에 존재하는 상품이면 순위가 포함되어 반환된다") + @Test + void shouldIncludeRanking_whenProductIsRanked() { + // given + ProductDetailInfo cachedInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.of(cachedInfo)); + when(likeReadService.isLikedBy(MEMBER_ID, PRODUCT_ID)).thenReturn(false); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(5); // 5위 + + // when + ProductDetailInfo result = productFacade.getProductDetail(PRODUCT_ID, MEMBER_ID); + + // then + assertThat(result.getRanking()).isEqualTo(5); + verify(productRankingCache).getRank(PRODUCT_ID); + } + + @DisplayName("랭킹에 존재하지 않는 상품이면 순위가 null로 반환된다") + @Test + void shouldReturnNullRanking_whenProductIsNotRanked() { + // given + ProductDetailInfo cachedInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.of(cachedInfo)); + when(likeReadService.isLikedBy(MEMBER_ID, PRODUCT_ID)).thenReturn(false); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(null); // 순위권 밖 + + // when + ProductDetailInfo result = productFacade.getProductDetail(PRODUCT_ID, MEMBER_ID); + + // then + assertThat(result.getRanking()).isNull(); + verify(productRankingCache).getRank(PRODUCT_ID); + } + + @DisplayName("랭킹 1위 상품이면 순위가 1로 반환된다") + @Test + void shouldReturnRanking1_whenProductIsFirstPlace() { + // given + ProductDetailInfo cachedInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.of(cachedInfo)); + when(likeReadService.isLikedBy(MEMBER_ID, PRODUCT_ID)).thenReturn(true); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(1); // 1위 + + // when + ProductDetailInfo result = productFacade.getProductDetail(PRODUCT_ID, MEMBER_ID); + + // then + assertThat(result.getRanking()).isEqualTo(1); + assertThat(result.isLikedByMember()).isTrue(); + } + + @DisplayName("비로그인 사용자도 랭킹 정보가 포함되어 반환된다") + @Test + void shouldIncludeRanking_whenUserNotLoggedIn() { + // given + ProductDetailInfo cachedInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.of(cachedInfo)); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(10); // 10위 + + // when + ProductDetailInfo result = productFacade.getProductDetail(PRODUCT_ID, null); + + // then + assertThat(result.getRanking()).isEqualTo(10); + assertThat(result.isLikedByMember()).isFalse(); + verify(likeReadService, never()).isLikedBy(anyLong(), anyLong()); + } + + @DisplayName("캐시 miss 시 DB 조회 후에도 랭킹 정보가 포함된다") + @Test + void shouldIncludeRanking_whenCacheMiss() { + // given + ProductDetailInfo dbInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.empty()); // 캐시 miss + when(productReadService.getProductDetail(PRODUCT_ID, null)).thenReturn(dbInfo); + when(likeReadService.isLikedBy(MEMBER_ID, PRODUCT_ID)).thenReturn(false); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(3); + + // when + ProductDetailInfo result = productFacade.getProductDetail(PRODUCT_ID, MEMBER_ID); + + // then + assertThat(result.getRanking()).isEqualTo(3); + verify(productDetailCache).set(PRODUCT_ID, dbInfo); // 캐시 저장 확인 + verify(productRankingCache).getRank(PRODUCT_ID); + } + + @DisplayName("상품 조회 이벤트가 발행된다") + @Test + void shouldPublishProductViewedEvent() { + // given + ProductDetailInfo cachedInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.of(cachedInfo)); + when(likeReadService.isLikedBy(MEMBER_ID, PRODUCT_ID)).thenReturn(false); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(5); + + // when + productFacade.getProductDetail(PRODUCT_ID, MEMBER_ID); + + // then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ProductViewedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + ProductViewedEvent capturedEvent = eventCaptor.getValue(); + assertThat(capturedEvent.productId()).isEqualTo(PRODUCT_ID); + assertThat(capturedEvent.memberId()).isEqualTo(MEMBER_ID); + assertThat(capturedEvent.brandId()).isEqualTo(BRAND_ID); + } + + @DisplayName("다른 필드들이 올바르게 유지된다") + @Test + void shouldPreserveOtherFields() { + // given + ProductDetailInfo cachedInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.of(cachedInfo)); + when(likeReadService.isLikedBy(MEMBER_ID, PRODUCT_ID)).thenReturn(true); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(7); + + // when + ProductDetailInfo result = productFacade.getProductDetail(PRODUCT_ID, MEMBER_ID); + + // then + assertThat(result.getId()).isEqualTo(PRODUCT_ID); + assertThat(result.getName()).isEqualTo("테스트 상품"); + assertThat(result.getDescription()).isEqualTo("테스트 설명"); + assertThat(result.getBrandId()).isEqualTo(BRAND_ID); + assertThat(result.getBrandName()).isEqualTo("테스트 브랜드"); + assertThat(result.getBrandDescription()).isEqualTo("브랜드 설명"); + assertThat(result.getPrice().getAmount().intValue()).isEqualTo(10000); + assertThat(result.getStock().getQuantity()).isEqualTo(100); + assertThat(result.getLikeCount()).isEqualTo(50); + assertThat(result.isLikedByMember()).isTrue(); + assertThat(result.getRanking()).isEqualTo(7); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java new file mode 100644 index 000000000..b96303e10 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java @@ -0,0 +1,202 @@ +package com.loopers.application.ranking; + +import com.loopers.application.ranking.RankingInfo.RankingItemInfo; +import com.loopers.application.ranking.RankingInfo.RankingPageInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.repository.BrandRepository; +import com.loopers.domain.common.vo.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.repository.ProductRepository; +import com.loopers.infrastructure.cache.ProductRankingCache; +import com.loopers.infrastructure.cache.ProductRankingCache.RankingEntry; +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 java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RankingFacadeTest { + + @Mock + private ProductRankingCache productRankingCache; + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + @InjectMocks + private RankingFacade rankingFacade; + + private static final String TODAY_DATE = "20251226"; + + @Test + @DisplayName("getRankings - 상품 정보가 Aggregation 된다") + void getRankings_aggregatesProductInfo() { + // given + List rankingEntries = List.of( + new RankingEntry(1, 100L, 50.0), + new RankingEntry(2, 200L, 40.0) + ); + + Product product1 = createProduct(100L, "상품A", 1L, 10000); + Product product2 = createProduct(200L, "상품B", 2L, 20000); + Brand brand1 = createBrand(1L, "브랜드A"); + Brand brand2 = createBrand(2L, "브랜드B"); + + when(productRankingCache.getTodayDate()).thenReturn(TODAY_DATE); + when(productRankingCache.getTopRankings(TODAY_DATE, 0, 10)).thenReturn(rankingEntries); + when(productRankingCache.getTotalCount(TODAY_DATE)).thenReturn(2L); + when(productRepository.findByIdIn(List.of(100L, 200L))).thenReturn(List.of(product1, product2)); + when(brandRepository.findByIdIn(List.of(1L, 2L))).thenReturn(List.of(brand1, brand2)); + + // when + RankingPageInfo result = rankingFacade.getRankings(null, 0, 10); + + // then + assertThat(result.rankings()).hasSize(2); + + RankingItemInfo first = result.rankings().get(0); + assertThat(first.rank()).isEqualTo(1); + assertThat(first.productId()).isEqualTo(100L); + assertThat(first.productName()).isEqualTo("상품A"); + assertThat(first.brandName()).isEqualTo("브랜드A"); + assertThat(first.score()).isEqualTo(50.0); + } + + @Test + @DisplayName("getRankings - 존재하지 않는 상품은 필터링된다") + void getRankings_filtersNonExistentProducts() { + // given + List rankingEntries = List.of( + new RankingEntry(1, 100L, 50.0), + new RankingEntry(2, 999L, 40.0) // 존재하지 않는 상품 + ); + + Product product1 = createProduct(100L, "상품A", 1L, 10000); + Brand brand1 = createBrand(1L, "브랜드A"); + + when(productRankingCache.getTodayDate()).thenReturn(TODAY_DATE); + when(productRankingCache.getTopRankings(TODAY_DATE, 0, 10)).thenReturn(rankingEntries); + when(productRankingCache.getTotalCount(TODAY_DATE)).thenReturn(2L); + when(productRepository.findByIdIn(anyList())).thenReturn(List.of(product1)); + when(brandRepository.findByIdIn(anyList())).thenReturn(List.of(brand1)); + + // when + RankingPageInfo result = rankingFacade.getRankings(null, 0, 10); + + // then + assertThat(result.rankings()).hasSize(1); + assertThat(result.rankings().get(0).productId()).isEqualTo(100L); + } + + @Test + @DisplayName("getRankings - 날짜 미지정 시 오늘 날짜 사용") + void getRankings_usesTodayDateWhenNotSpecified() { + // given + when(productRankingCache.getTodayDate()).thenReturn(TODAY_DATE); + when(productRankingCache.getTopRankings(TODAY_DATE, 0, 10)).thenReturn(Collections.emptyList()); + + // when + RankingPageInfo result = rankingFacade.getRankings(null, 0, 10); + + // then + assertThat(result.date()).isEqualTo(TODAY_DATE); + verify(productRankingCache).getTopRankings(eq(TODAY_DATE), anyInt(), anyInt()); + } + + @Test + @DisplayName("getRankings - 페이지 정보가 정확하다") + void getRankings_pageInfoIsCorrect() { + // given - 첫 페이지에 10개 상품이 있고, 전체 25개 + List rankingEntries = List.of( + new RankingEntry(1, 100L, 50.0) + ); + Product product = createProduct(100L, "상품A", 1L, 10000); + Brand brand = createBrand(1L, "브랜드A"); + + when(productRankingCache.getTodayDate()).thenReturn(TODAY_DATE); + when(productRankingCache.getTopRankings(TODAY_DATE, 0, 10)).thenReturn(rankingEntries); + when(productRankingCache.getTotalCount(TODAY_DATE)).thenReturn(25L); + when(productRepository.findByIdIn(anyList())).thenReturn(List.of(product)); + when(brandRepository.findByIdIn(anyList())).thenReturn(List.of(brand)); + + // when + RankingPageInfo result = rankingFacade.getRankings(null, 0, 10); + + // then + assertThat(result.page()).isEqualTo(0); + assertThat(result.size()).isEqualTo(10); + assertThat(result.totalCount()).isEqualTo(25); + assertThat(result.totalPages()).isEqualTo(3); // ceil(25/10) + assertThat(result.hasNext()).isTrue(); + } + + @Test + @DisplayName("getRankings - 빈 결과 시 빈 리스트 반환") + void getRankings_returnsEmptyListWhenNoResults() { + // given + when(productRankingCache.getTodayDate()).thenReturn(TODAY_DATE); + when(productRankingCache.getTopRankings(TODAY_DATE, 0, 10)).thenReturn(Collections.emptyList()); + + // when + RankingPageInfo result = rankingFacade.getRankings(null, 0, 10); + + // then + assertThat(result.rankings()).isEmpty(); + assertThat(result.totalCount()).isEqualTo(0); + assertThat(result.hasNext()).isFalse(); + } + + private Product createProduct(Long id, String name, Long brandId, int price) { + Product product = mock(Product.class); + when(product.getId()).thenReturn(id); + when(product.getName()).thenReturn(name); + when(product.getBrandId()).thenReturn(brandId); + when(product.getPrice()).thenReturn(Money.of(price)); + when(product.getLikeCount()).thenReturn(10); + return product; + } + + private Brand createBrand(Long id, String name) { + Brand brand = mock(Brand.class); + when(brand.getId()).thenReturn(id); + when(brand.getName()).thenReturn(name); + return brand; + } + + @Test + @DisplayName("getRankings - 잘못된 날짜 형식은 예외 발생") + void getRankings_throwsExceptionForInvalidDateFormat() { + // given + String invalidDate = "2025-12-26"; // yyyy-MM-dd 형식 (잘못됨) + + // when & then + assertThatThrownBy(() -> rankingFacade.getRankings(invalidDate, 0, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid date format"); + } + + @Test + @DisplayName("getRankings - 존재하지 않는 날짜는 예외 발생") + void getRankings_throwsExceptionForNonExistentDate() { + // given + String invalidDate = "20251332"; // 13월 32일 (존재하지 않는 날짜) + + // when & then + assertThatThrownBy(() -> rankingFacade.getRankings(invalidDate, 0, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid date format"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java index c72057eb2..87692e6f1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.like; +import com.loopers.application.like.LikeFacade; import com.loopers.domain.common.vo.Money; import com.loopers.domain.like.repository.LikeRepository; import com.loopers.domain.like.service.LikeService; @@ -23,6 +24,9 @@ class LikeServiceIntegrationTest { @Autowired private LikeService likeService; + @Autowired + private LikeFacade likeFacade; + @Autowired private LikeRepository likeRepository; @@ -76,10 +80,10 @@ void duplicateLike() { Member member = memberRepository.save(createMember("user1", "u1@mail.com")); Product product = productRepository.save(createProduct(1L, "상품A", 1000L, 10)); - likeService.like(member.getId(), product.getId()); + likeFacade.likeProduct(member.getId(), product.getId()); // when - likeService.like(member.getId(), product.getId()); // 중복 호출 + likeFacade.likeProduct(member.getId(), product.getId()); // 중복 호출 - Facade에서 멱등성 보장 // then long likeCount = likeRepository.countByProductId(product.getId()); diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java new file mode 100644 index 000000000..739e32f3e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java @@ -0,0 +1,169 @@ +package com.loopers.infrastructure.cache; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ProductRankingCacheTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ZSetOperations zSetOperations; + + private ProductRankingCache productRankingCache; + + private static final Long PRODUCT_ID = 100L; + private static final String TODAY_DATE = "20251226"; + + @BeforeEach + void setUp() { + productRankingCache = new ProductRankingCache(redisTemplate); + when(redisTemplate.opsForZSet()).thenReturn(zSetOperations); + } + + @Test + @DisplayName("getRank - 순위가 1-based로 반환된다") + void getRank_returnsOneBasedRank() { + // given + when(zSetOperations.reverseRank(anyString(), eq(PRODUCT_ID.toString()))) + .thenReturn(0L); // 0-based rank + + // when + Integer rank = productRankingCache.getRank(PRODUCT_ID, TODAY_DATE); + + // then + assertThat(rank).isEqualTo(1); // 1-based + } + + @Test + @DisplayName("getRank - 순위권 밖이면 null 반환") + void getRank_returnsNullWhenNotRanked() { + // given + when(zSetOperations.reverseRank(anyString(), eq(PRODUCT_ID.toString()))) + .thenReturn(null); + + // when + Integer rank = productRankingCache.getRank(PRODUCT_ID, TODAY_DATE); + + // then + assertThat(rank).isNull(); + } + + @Test + @DisplayName("getScore - 점수가 정상 반환된다") + void getScore_returnsScore() { + // given + when(zSetOperations.score(anyString(), eq(PRODUCT_ID.toString()))) + .thenReturn(15.5); + + // when + Double score = productRankingCache.getScore(PRODUCT_ID, TODAY_DATE); + + // then + assertThat(score).isEqualTo(15.5); + } + + @Test + @DisplayName("getTopRankings - 페이지네이션이 동작한다") + void getTopRankings_paginationWorks() { + // given + int page = 1; + int size = 10; + long expectedStart = 10L; // page * size + long expectedEnd = 19L; // start + size - 1 + + Set> mockResults = new LinkedHashSet<>(); + mockResults.add(createTuple("101", 50.0)); + mockResults.add(createTuple("102", 45.0)); + + when(zSetOperations.reverseRangeWithScores(anyString(), eq(expectedStart), eq(expectedEnd))) + .thenReturn(mockResults); + + // when + List rankings = + productRankingCache.getTopRankings(TODAY_DATE, page, size); + + // then + assertThat(rankings).hasSize(2); + assertThat(rankings.get(0).rank()).isEqualTo(11); // page * size + 1 + assertThat(rankings.get(0).productId()).isEqualTo(101L); + assertThat(rankings.get(0).score()).isEqualTo(50.0); + } + + @Test + @DisplayName("getTopRankings - 빈 결과 시 빈 리스트 반환") + void getTopRankings_returnsEmptyListWhenNoResults() { + // given + when(zSetOperations.reverseRangeWithScores(anyString(), anyLong(), anyLong())) + .thenReturn(Collections.emptySet()); + + // when + List rankings = + productRankingCache.getTopRankings(TODAY_DATE, 0, 10); + + // then + assertThat(rankings).isEmpty(); + } + + @Test + @DisplayName("getTotalCount - 전체 개수를 반환한다") + void getTotalCount_returnsTotalCount() { + // given + when(zSetOperations.zCard(anyString())).thenReturn(150L); + + // when + long count = productRankingCache.getTotalCount(TODAY_DATE); + + // then + assertThat(count).isEqualTo(150); + } + + @Test + @DisplayName("getTotalCount - null일 경우 0 반환") + void getTotalCount_returnsZeroWhenNull() { + // given + when(zSetOperations.zCard(anyString())).thenReturn(null); + + // when + long count = productRankingCache.getTotalCount(TODAY_DATE); + + // then + assertThat(count).isEqualTo(0); + } + + private ZSetOperations.TypedTuple createTuple(String value, Double score) { + return new ZSetOperations.TypedTuple<>() { + @Override + public Object getValue() { + return value; + } + + @Override + public Double getScore() { + return score; + } + + @Override + public int compareTo(ZSetOperations.TypedTuple o) { + return Double.compare(score, o.getScore()); + } + }; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java index c17107ac0..8479682f9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java @@ -9,23 +9,31 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.vo.Stock; import com.loopers.domain.product.repository.ProductRepository; +import com.loopers.infrastructure.cache.CacheKeyGenerator; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.testcontainers.RedisTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import com.loopers.utils.RestPage; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + import java.math.BigDecimal; import static org.assertj.core.api.Assertions.assertThat; @@ -33,6 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(RedisTestContainersConfig.class) class ProductV1ApiE2ETest { private final TestRestTemplate testRestTemplate; @@ -40,6 +49,9 @@ class ProductV1ApiE2ETest { private final BrandRepository brandRepository; private final ProductRepository productRepository; private final DatabaseCleanUp databaseCleanUp; + private final RedisTemplate redisTemplate; + + private String todayDate; @Autowired public ProductV1ApiE2ETest( @@ -47,20 +59,34 @@ public ProductV1ApiE2ETest( MemberFacade memberFacade, BrandRepository brandRepository, ProductRepository productRepository, - DatabaseCleanUp databaseCleanUp + DatabaseCleanUp databaseCleanUp, + RedisTemplate redisTemplate ) { this.testRestTemplate = testRestTemplate; this.memberFacade = memberFacade; this.brandRepository = brandRepository; this.productRepository = productRepository; this.databaseCleanUp = databaseCleanUp; + this.redisTemplate = redisTemplate; + } + + @BeforeEach + void setUp() { + todayDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + redisTemplate.getConnectionFactory().getConnection().flushDb(); } @AfterEach void tearDown() { + redisTemplate.getConnectionFactory().getConnection().flushDb(); databaseCleanUp.truncateAllTables(); } + private void addToRanking(Long productId, double score) { + String key = CacheKeyGenerator.dailyRankingKey(todayDate); + redisTemplate.opsForZSet().add(key, productId.toString(), score); + } + private record TestContext(Long memberId, Long productId) {} private TestContext setupProductAndMember() { @@ -287,6 +313,136 @@ void shouldReturn404_whenProductNotFound() { () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) ); } + + @DisplayName("랭킹에 존재하는 상품 조회 시 순위가 포함되어 반환된다") + @Test + void shouldIncludeRanking_whenProductIsRanked() { + // given + TestContext context = setupProductAndMember(); + Long productId = context.productId(); + + // 랭킹에 상품 추가 (1위) + addToRanking(productId, 100.0); + + HttpHeaders headers = headersOf(context.memberId()); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/products/" + productId, HttpMethod.GET, + new HttpEntity<>(null, headers), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().ranking()).isEqualTo(1) + ); + } + + @DisplayName("여러 상품이 랭킹에 있을 때 올바른 순위가 반환된다") + @Test + void shouldReturnCorrectRanking_whenMultipleProductsRanked() { + // given + Long memberId = memberFacade.registerMember("test123", "test@example.com", "password", "1990-01-01", Gender.MALE).id(); + Brand brand = brandRepository.save(new Brand("TestBrand", "Test Brand Description")); + + Product product1 = productRepository.save(new Product( + brand.getId(), "상품1", "설명1", + Money.of(BigDecimal.valueOf(10000)), Stock.of(100))); + Product product2 = productRepository.save(new Product( + brand.getId(), "상품2", "설명2", + Money.of(BigDecimal.valueOf(20000)), Stock.of(100))); + Product product3 = productRepository.save(new Product( + brand.getId(), "상품3", "설명3", + Money.of(BigDecimal.valueOf(30000)), Stock.of(100))); + + // 랭킹 추가 (점수가 높을수록 순위가 높음) + addToRanking(product1.getId(), 100.0); // 1위 + addToRanking(product2.getId(), 80.0); // 2위 + addToRanking(product3.getId(), 60.0); // 3위 + + HttpHeaders headers = headersOf(memberId); + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when & then - 1위 상품 조회 + ResponseEntity> response1 = + testRestTemplate.exchange("/api/v1/products/" + product1.getId(), HttpMethod.GET, + new HttpEntity<>(null, headers), responseType); + assertThat(response1.getBody().data().ranking()).isEqualTo(1); + + // when & then - 2위 상품 조회 + ResponseEntity> response2 = + testRestTemplate.exchange("/api/v1/products/" + product2.getId(), HttpMethod.GET, + new HttpEntity<>(null, headers), responseType); + assertThat(response2.getBody().data().ranking()).isEqualTo(2); + + // when & then - 3위 상품 조회 + ResponseEntity> response3 = + testRestTemplate.exchange("/api/v1/products/" + product3.getId(), HttpMethod.GET, + new HttpEntity<>(null, headers), responseType); + assertThat(response3.getBody().data().ranking()).isEqualTo(3); + } + + @DisplayName("랭킹에 존재하지 않는 상품 조회 시 ranking이 null로 반환된다") + @Test + void shouldReturnNullRanking_whenProductNotRanked() { + // given + TestContext context = setupProductAndMember(); + Long productId = context.productId(); + + // 랭킹에 추가하지 않음 + + HttpHeaders headers = headersOf(context.memberId()); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/products/" + productId, HttpMethod.GET, + new HttpEntity<>(null, headers), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().ranking()).isNull() + ); + } + + @DisplayName("비로그인 사용자도 상품 조회 시 랭킹 정보가 포함된다") + @Test + void shouldIncludeRanking_whenUserNotLoggedIn() { + // given + Brand brand = brandRepository.save(new Brand("TestBrand", "Test Brand Description")); + Product product = productRepository.save(new Product( + brand.getId(), "테스트 상품", "설명", + Money.of(BigDecimal.valueOf(10000)), Stock.of(100))); + + addToRanking(product.getId(), 50.0); + + // when - X-USER-ID 헤더 없이 요청 + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/products/" + product.getId(), HttpMethod.GET, + new HttpEntity<>(null, null), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().ranking()).isEqualTo(1), + () -> assertThat(response.getBody().data().isLikedByMember()).isFalse() + ); + } } @DisplayName("인기 상품 TOP100 조회 (GET /api/v1/products/popular)") diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java new file mode 100644 index 000000000..e7efeb2e7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java @@ -0,0 +1,263 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.repository.BrandRepository; +import com.loopers.domain.common.vo.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.repository.ProductRepository; +import com.loopers.domain.product.vo.Stock; +import com.loopers.infrastructure.cache.CacheKeyGenerator; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(RedisTestContainersConfig.class) +@DisplayName("랭킹 API E2E 테스트") +class RankingV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private String todayDate; + private Brand testBrand; + + @BeforeEach + void setUp() { + todayDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + // Redis 초기화 + redisTemplate.getConnectionFactory().getConnection().flushDb(); + + // 테스트 브랜드 생성 + testBrand = brandRepository.save(new Brand("TestBrand", "Test Brand Description")); + } + + @AfterEach + void tearDown() { + redisTemplate.getConnectionFactory().getConnection().flushDb(); + databaseCleanUp.truncateAllTables(); + } + + private Product createProduct(String name, int price) { + Product product = new Product( + testBrand.getId(), + name, + "Description", + Money.of(BigDecimal.valueOf(price)), + Stock.of(100) + ); + return productRepository.save(product); + } + + private void addToRanking(Long productId, double score) { + String key = CacheKeyGenerator.dailyRankingKey(todayDate); + redisTemplate.opsForZSet().add(key, productId.toString(), score); + } + + @DisplayName("랭킹 조회 (GET /api/v1/rankings)") + @Nested + class GetRankings { + + @DisplayName("랭킹 데이터가 있을 때 정상 응답을 반환한다") + @Test + void shouldReturnRankings_whenDataExists() { + // given + Product product1 = createProduct("인기상품1", 10000); + Product product2 = createProduct("인기상품2", 20000); + Product product3 = createProduct("인기상품3", 30000); + + addToRanking(product1.getId(), 100.0); + addToRanking(product2.getId(), 80.0); + addToRanking(product3.getId(), 60.0); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/rankings", HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().rankings()).hasSize(3), + () -> assertThat(response.getBody().data().date()).isEqualTo(todayDate), + // 1위는 점수가 가장 높은 product1 + () -> assertThat(response.getBody().data().rankings().get(0).rank()).isEqualTo(1), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(product1.getId()), + () -> assertThat(response.getBody().data().rankings().get(0).productName()).isEqualTo("인기상품1"), + () -> assertThat(response.getBody().data().rankings().get(0).brandName()).isEqualTo("TestBrand"), + () -> assertThat(response.getBody().data().rankings().get(0).score()).isEqualTo(100.0), + // 2위 + () -> assertThat(response.getBody().data().rankings().get(1).rank()).isEqualTo(2), + () -> assertThat(response.getBody().data().rankings().get(1).productId()).isEqualTo(product2.getId()), + // 3위 + () -> assertThat(response.getBody().data().rankings().get(2).rank()).isEqualTo(3), + () -> assertThat(response.getBody().data().rankings().get(2).productId()).isEqualTo(product3.getId()) + ); + } + + @DisplayName("page 파라미터가 1-based로 동작한다") + @Test + void shouldWork_with1BasedPageParameter() { + // given - 25개 상품 생성 및 랭킹 추가 + for (int i = 1; i <= 25; i++) { + Product product = createProduct("상품" + i, 10000 * i); + addToRanking(product.getId(), 100.0 - i); // 상품1이 1위 + } + + // when - page=1 (첫 페이지) + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/rankings?page=1&size=10", HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().rankings()).hasSize(10), + () -> assertThat(response.getBody().data().page()).isEqualTo(1), + () -> assertThat(response.getBody().data().totalCount()).isEqualTo(25), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(3), + () -> assertThat(response.getBody().data().hasNext()).isTrue(), + // 첫 페이지는 1위~10위 + () -> assertThat(response.getBody().data().rankings().get(0).rank()).isEqualTo(1), + () -> assertThat(response.getBody().data().rankings().get(9).rank()).isEqualTo(10) + ); + + // when - page=2 (두 번째 페이지) + ResponseEntity> response2 = + testRestTemplate.exchange("/api/v1/rankings?page=2&size=10", HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response2.getStatusCode().is2xxSuccessful()), + () -> assertThat(response2.getBody().data().rankings()).hasSize(10), + () -> assertThat(response2.getBody().data().page()).isEqualTo(2), + // 두 번째 페이지는 11위~20위 + () -> assertThat(response2.getBody().data().rankings().get(0).rank()).isEqualTo(11), + () -> assertThat(response2.getBody().data().rankings().get(9).rank()).isEqualTo(20) + ); + + // when - page=3 (마지막 페이지) + ResponseEntity> response3 = + testRestTemplate.exchange("/api/v1/rankings?page=3&size=10", HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response3.getStatusCode().is2xxSuccessful()), + () -> assertThat(response3.getBody().data().rankings()).hasSize(5), // 21~25위 + () -> assertThat(response3.getBody().data().page()).isEqualTo(3), + () -> assertThat(response3.getBody().data().hasNext()).isFalse(), + () -> assertThat(response3.getBody().data().rankings().get(0).rank()).isEqualTo(21), + () -> assertThat(response3.getBody().data().rankings().get(4).rank()).isEqualTo(25) + ); + } + + @DisplayName("랭킹 데이터가 없을 때 빈 배열을 반환한다") + @Test + void shouldReturnEmptyList_whenNoRankingData() { + // given - 랭킹 데이터 없음 + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/rankings", HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().rankings()).isEmpty(), + () -> assertThat(response.getBody().data().totalCount()).isEqualTo(0), + () -> assertThat(response.getBody().data().hasNext()).isFalse() + ); + } + + @DisplayName("date 파라미터로 특정 날짜의 랭킹을 조회할 수 있다") + @Test + void shouldReturnRankings_forSpecificDate() { + // given - 어제 날짜의 랭킹 데이터 생성 + String yesterday = LocalDate.now().minusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd")); + Product product = createProduct("어제상품", 10000); + + String yesterdayKey = CacheKeyGenerator.dailyRankingKey(yesterday); + redisTemplate.opsForZSet().add(yesterdayKey, product.getId().toString(), 50.0); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/rankings?date=" + yesterday, HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().date()).isEqualTo(yesterday), + () -> assertThat(response.getBody().data().rankings()).hasSize(1), + () -> assertThat(response.getBody().data().rankings().get(0).productName()).isEqualTo("어제상품") + ); + } + + @DisplayName("존재하지 않는 상품은 랭킹에서 필터링된다") + @Test + void shouldFilterNonExistentProducts() { + // given + Product existingProduct = createProduct("존재하는상품", 10000); + Long nonExistentProductId = 99999L; + + addToRanking(existingProduct.getId(), 100.0); + addToRanking(nonExistentProductId, 80.0); // DB에 없는 상품 + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/rankings", HttpMethod.GET, null, responseType); + + // then - 존재하는 상품만 반환됨 + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().rankings()).hasSize(1), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(existingProduct.getId()) + ); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java index cea286f32..59427a31f 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java @@ -6,6 +6,8 @@ import com.loopers.application.event.product.ProductViewedEvent; import com.loopers.domain.event.EventHandled; import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.infrastructure.cache.ProductRankingCache; +import com.loopers.infrastructure.cache.ProductRankingCache.OrderItemScore; import com.loopers.infrastructure.persistence.EventHandledRepository; import com.loopers.infrastructure.persistence.ProductMetricsRepository; import lombok.RequiredArgsConstructor; @@ -13,6 +15,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; + /** * 메트릭 집계 서비스 * - Kafka 이벤트를 받아서 ProductMetrics 테이블 업데이트 @@ -26,6 +30,7 @@ public class MetricsAggregationService { private final ProductMetricsRepository metricsRepository; private final EventHandledRepository eventHandledRepository; + private final ProductRankingCache productRankingCache; /** * 상품 좋아요 이벤트 처리 @@ -44,6 +49,9 @@ public void handleProductLiked(String eventId, ProductLikedEvent payload) { metrics.incrementLikeCount(); metricsRepository.save(metrics); + // 랭킹 ZSET 점수 추가 + productRankingCache.addLikeScore(payload.productId()); + // 처리 완료 기록 eventHandledRepository.save( EventHandled.create(eventId, "PRODUCT_LIKED", String.valueOf(payload.productId())) @@ -55,6 +63,7 @@ public void handleProductLiked(String eventId, ProductLikedEvent payload) { /** * 상품 좋아요 취소 이벤트 처리 + * - 랭킹 점수도 함께 감소 (일관성 유지) */ public void handleProductUnliked(String eventId, ProductUnlikedEvent payload) { // 멱등성 체크 @@ -70,6 +79,9 @@ public void handleProductUnliked(String eventId, ProductUnlikedEvent payload) { metrics.decrementLikeCount(); metricsRepository.save(metrics); + // 랭킹 ZSET 점수 감소 (좋아요 취소 반영) + productRankingCache.subtractLikeScore(payload.productId()); + // 처리 완료 기록 eventHandledRepository.save( EventHandled.create(eventId, "PRODUCT_UNLIKED", String.valueOf(payload.productId())) @@ -81,6 +93,7 @@ public void handleProductUnliked(String eventId, ProductUnlikedEvent payload) { /** * 주문 완료 이벤트 처리 (판매량 집계) + * - Redis Pipeline을 사용하여 여러 아이템의 랭킹 점수를 한 번에 업데이트 */ public void handleOrderCompleted(String eventId, OrderCompletedEvent payload) { // 멱등성 체크 @@ -89,6 +102,9 @@ public void handleOrderCompleted(String eventId, OrderCompletedEvent payload) { return; } + // 랭킹 점수 배치 처리용 목록 + var orderItemScores = new ArrayList(); + // 각 주문 아이템별로 ProductMetrics 업데이트 for (var item : payload.items()) { ProductMetrics metrics = metricsRepository.findById(item.productId()) @@ -99,10 +115,20 @@ public void handleOrderCompleted(String eventId, OrderCompletedEvent payload) { metrics.addSales(item.quantity(), totalAmount); metricsRepository.save(metrics); + // 랭킹 점수 배치 목록에 추가 + orderItemScores.add(new OrderItemScore( + item.productId(), + item.price().longValue(), + item.quantity() + )); + log.debug("[Metrics] Sales updated - productId: {}, quantity: {}, amount: {}", item.productId(), item.quantity(), totalAmount); } + // 랭킹 ZSET 점수 배치 추가 (Pipeline 사용) + productRankingCache.addOrderScoresBatch(orderItemScores); + // 처리 완료 기록 eventHandledRepository.save( EventHandled.create(eventId, "ORDER_COMPLETED", payload.orderNo()) @@ -129,6 +155,9 @@ public void handleProductViewed(String eventId, ProductViewedEvent payload) { metrics.incrementViewCount(); metricsRepository.save(metrics); + // 랭킹 ZSET 점수 추가 + productRankingCache.addViewScore(payload.productId()); + // 처리 완료 기록 eventHandledRepository.save( EventHandled.create(eventId, "PRODUCT_VIEWED", String.valueOf(payload.productId())) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/config/RankingConfig.java b/apps/commerce-streamer/src/main/java/com/loopers/config/RankingConfig.java new file mode 100644 index 000000000..180e89113 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/config/RankingConfig.java @@ -0,0 +1,24 @@ +package com.loopers.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "ranking") +public class RankingConfig { + + private Weight weight = new Weight(); + private int ttlDays = 2; + + @Getter + @Setter + public static class Weight { + private double view = 0.1; + private double like = 0.2; + private double order = 0.6; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java new file mode 100644 index 000000000..5eb7f1558 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.cache; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class CacheConfig { + + @Bean + public RedisTemplate cacheRedisTemplate(LettuceConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // Key serializer + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + // Value serializer - JSON + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper); + template.setValueSerializer(jsonSerializer); + template.setHashValueSerializer(jsonSerializer); + + template.afterPropertiesSet(); + return template; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java new file mode 100644 index 000000000..0b9887daf --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.cache; + +/** + * Redis 캐시 키 생성기 (commerce-streamer) + * commerce-api의 CacheKeyGenerator와 동일한 키 형식을 사용 + */ +public class CacheKeyGenerator { + + private static final String VERSION = "v1"; + + /** + * Daily ranking ZSET key + * 형식: ranking:all:v1:{yyyyMMdd} + * 예: ranking:all:v1:20251224 + */ + public static String dailyRankingKey(String date) { + return String.format("ranking:all:%s:%s", VERSION, date); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java new file mode 100644 index 000000000..5a6a36f2a --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java @@ -0,0 +1,159 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.config.RankingConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.SessionCallback; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * 상품 랭킹 ZSET 캐시 (적재용 - commerce-streamer) + * Key: ranking:all:v1:{yyyyMMdd} + * Member: productId + * Score: 가중치 적용된 점수 + */ +@Slf4j +@Component +public class ProductRankingCache { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); + + private final RedisTemplate cacheRedisTemplate; + private final RankingConfig rankingConfig; + + /** + * TTL이 설정된 키를 추적 (Race Condition 방지) + * 날짜가 바뀌면 자동으로 새 키에 대해 TTL 설정 + */ + private final AtomicReference ttlInitializedKey = new AtomicReference<>(); + + public ProductRankingCache(RedisTemplate cacheRedisTemplate, RankingConfig rankingConfig) { + this.cacheRedisTemplate = cacheRedisTemplate; + this.rankingConfig = rankingConfig; + } + + public void addViewScore(Long productId) { + double score = rankingConfig.getWeight().getView(); + incrementScore(productId, score); + log.debug("[Ranking] View score added - productId: {}, score: {}", productId, score); + } + + public void addLikeScore(Long productId) { + double score = rankingConfig.getWeight().getLike(); + incrementScore(productId, score); + log.debug("[Ranking] Like score added - productId: {}, score: {}", productId, score); + } + + /** 점수가 0 이하가 되어도 ZSET에서 자동 제거되지 않음 (일관성 유지) */ + public void subtractLikeScore(Long productId) { + double score = rankingConfig.getWeight().getLike(); + decrementScore(productId, score); + log.debug("[Ranking] Like score subtracted - productId: {}, score: -{}", productId, score); + } + + /** log 정규화로 고가 상품의 과도한 점수 편중 방지: log1p(price * qty) */ + public void addOrderScore(Long productId, long price, int quantity) { + double weight = rankingConfig.getWeight().getOrder(); + long orderAmount = price * quantity; + // log 정규화: 가격 스케일 차이를 완화하여 공정한 랭킹 반영 + // 예: 100,000원 → log1p(100000) ≈ 11.5, 1,000원 → log1p(1000) ≈ 6.9 + double score = weight * Math.log1p(orderAmount); + incrementScore(productId, score); + log.debug("[Ranking] Order score added - productId: {}, price: {}, qty: {}, amount: {}, score: {}", + productId, price, quantity, orderAmount, score); + } + + /** Pipeline으로 여러 아이템을 한 번의 Redis 요청으로 처리 */ + public void addOrderScoresBatch(List orderItems) { + if (orderItems == null || orderItems.isEmpty()) { + return; + } + + try { + String key = getTodayKey(); + double weight = rankingConfig.getWeight().getOrder(); + + // Pipeline으로 여러 ZINCRBY를 한 번에 실행 + cacheRedisTemplate.executePipelined(new SessionCallback<>() { + @Override + @SuppressWarnings("unchecked") + public Object execute(RedisOperations operations) throws DataAccessException { + for (OrderItemScore item : orderItems) { + long orderAmount = item.price() * item.quantity(); + double score = weight * Math.log1p(orderAmount); + operations.opsForZSet().incrementScore(key, item.productId().toString(), score); + } + return null; + } + }); + + // TTL 설정 (CAS로 원자적 처리) + ensureTtl(key); + + log.debug("[Ranking] Batch order scores added - {} items", orderItems.size()); + } catch (Exception e) { + log.warn("[Ranking] Failed to add batch order scores - error: {}", e.getMessage()); + // Fallback: 개별 처리 + for (OrderItemScore item : orderItems) { + addOrderScore(item.productId(), item.price(), item.quantity()); + } + } + } + + public record OrderItemScore(Long productId, long price, int quantity) {} + + private void incrementScore(Long productId, double score) { + try { + String key = getTodayKey(); + cacheRedisTemplate.opsForZSet().incrementScore(key, productId.toString(), score); + ensureTtl(key); + } catch (Exception e) { + log.warn("[Ranking] Failed to increment score - productId: {}, error: {}", + productId, e.getMessage()); + } + } + + private void decrementScore(Long productId, double score) { + try { + String key = getTodayKey(); + // ZINCRBY는 음수 값으로 감소 가능 + cacheRedisTemplate.opsForZSet().incrementScore(key, productId.toString(), -score); + ensureTtl(key); + } catch (Exception e) { + log.warn("[Ranking] Failed to decrement score - productId: {}, error: {}", + productId, e.getMessage()); + } + } + + /** getAndSet으로 키 변경 시에만 TTL 설정 (날짜가 바뀌는 시점) */ + private void ensureTtl(String key) { + String oldKey = ttlInitializedKey.getAndSet(key); + if (!key.equals(oldKey)) { + cacheRedisTemplate.expire(key, rankingConfig.getTtlDays(), TimeUnit.DAYS); + log.info("[Ranking] TTL set for key: {} ({} days)", key, rankingConfig.getTtlDays()); + } + } + + /** Asia/Seoul 기준, commerce-api와 동일한 키 형식 */ + private String getTodayKey() { + String date = LocalDate.now(ZONE_ID).format(DATE_FORMATTER); + return CacheKeyGenerator.dailyRankingKey(date); + } + + public static String getKeyForDate(String date) { + return CacheKeyGenerator.dailyRankingKey(date); + } +} diff --git a/apps/commerce-streamer/src/main/resources/application.yml b/apps/commerce-streamer/src/main/resources/application.yml index 0651bc2bd..a031868da 100644 --- a/apps/commerce-streamer/src/main/resources/application.yml +++ b/apps/commerce-streamer/src/main/resources/application.yml @@ -29,6 +29,13 @@ demo-kafka: test: topic-name: demo.internal.topic-v1 +ranking: + weight: + view: 0.1 + like: 0.2 + order: 0.6 + ttl-days: 2 + --- spring: config: diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceIdempotencyTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceIdempotencyTest.java index a9f922461..8362c9589 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceIdempotencyTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceIdempotencyTest.java @@ -6,6 +6,7 @@ import com.loopers.application.event.product.ProductViewedEvent; import com.loopers.config.TestConfig; import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.infrastructure.cache.ProductRankingCache; import com.loopers.infrastructure.persistence.EventHandledRepository; import com.loopers.infrastructure.persistence.ProductMetricsRepository; import org.junit.jupiter.api.BeforeEach; @@ -13,6 +14,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; @@ -41,6 +43,9 @@ class MetricsAggregationServiceIdempotencyTest { @Autowired private EventHandledRepository eventHandledRepository; + @MockBean + private ProductRankingCache productRankingCache; + private static final Long PRODUCT_ID = 100L; private static final Long MEMBER_ID = 1L; private static final Long BRAND_ID = 10L; diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceRankingTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceRankingTest.java new file mode 100644 index 000000000..743decc78 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceRankingTest.java @@ -0,0 +1,164 @@ +package com.loopers.application.metrics; + +import com.loopers.application.event.like.ProductLikedEvent; +import com.loopers.application.event.like.ProductUnlikedEvent; +import com.loopers.application.event.order.OrderCompletedEvent; +import com.loopers.application.event.product.ProductViewedEvent; +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.infrastructure.cache.ProductRankingCache; +import com.loopers.infrastructure.cache.ProductRankingCache.OrderItemScore; +import com.loopers.infrastructure.persistence.EventHandledRepository; +import com.loopers.infrastructure.persistence.ProductMetricsRepository; +import org.junit.jupiter.api.DisplayName; +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 java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 메트릭 집계 서비스 - 랭킹 적재 테스트 + */ +@ExtendWith(MockitoExtension.class) +class MetricsAggregationServiceRankingTest { + + @Mock + private ProductMetricsRepository metricsRepository; + + @Mock + private EventHandledRepository eventHandledRepository; + + @Mock + private ProductRankingCache productRankingCache; + + @InjectMocks + private MetricsAggregationService aggregationService; + + private static final Long PRODUCT_ID = 100L; + private static final Long MEMBER_ID = 1L; + private static final Long BRAND_ID = 10L; + + private void setupNonDuplicateEvent() { + when(eventHandledRepository.existsById(anyString())).thenReturn(false); + when(metricsRepository.findById(PRODUCT_ID)) + .thenReturn(Optional.of(ProductMetrics.create(PRODUCT_ID))); + } + + @Test + @DisplayName("handleProductLiked - 랭킹 점수가 적재된다") + void handleProductLiked_addsRankingScore() { + // given + setupNonDuplicateEvent(); + String eventId = "event-liked-001"; + ProductLikedEvent event = new ProductLikedEvent( + MEMBER_ID, PRODUCT_ID, BRAND_ID, LocalDateTime.now() + ); + + // when + aggregationService.handleProductLiked(eventId, event); + + // then + verify(productRankingCache).addLikeScore(PRODUCT_ID); + } + + @Test + @DisplayName("handleProductUnliked - 랭킹 점수가 감소한다") + void handleProductUnliked_subtractsRankingScore() { + // given + setupNonDuplicateEvent(); + String eventId = "event-unliked-001"; + ProductUnlikedEvent event = new ProductUnlikedEvent( + MEMBER_ID, PRODUCT_ID, BRAND_ID, LocalDateTime.now() + ); + + // when + aggregationService.handleProductUnliked(eventId, event); + + // then + verify(productRankingCache).subtractLikeScore(PRODUCT_ID); + } + + @Test + @DisplayName("handleProductViewed - 랭킹 점수가 적재된다") + void handleProductViewed_addsRankingScore() { + // given + setupNonDuplicateEvent(); + String eventId = "event-viewed-001"; + ProductViewedEvent event = new ProductViewedEvent( + MEMBER_ID, PRODUCT_ID, BRAND_ID, LocalDateTime.now() + ); + + // when + aggregationService.handleProductViewed(eventId, event); + + // then + verify(productRankingCache).addViewScore(PRODUCT_ID); + } + + @Test + @DisplayName("handleOrderCompleted - 배치로 랭킹 점수가 적재된다") + void handleOrderCompleted_addsBatchRankingScore() { + // given + when(eventHandledRepository.existsById(anyString())).thenReturn(false); + String eventId = "event-order-001"; + Long productId1 = 100L; + Long productId2 = 200L; + + when(metricsRepository.findById(productId1)) + .thenReturn(Optional.of(ProductMetrics.create(productId1))); + when(metricsRepository.findById(productId2)) + .thenReturn(Optional.of(ProductMetrics.create(productId2))); + + OrderCompletedEvent event = new OrderCompletedEvent( + "ORDER-001", + MEMBER_ID, + BigDecimal.valueOf(75000), + List.of( + new OrderCompletedEvent.OrderItemInfo(productId1, 2, BigDecimal.valueOf(25000)), + new OrderCompletedEvent.OrderItemInfo(productId2, 1, BigDecimal.valueOf(25000)) + ), + LocalDateTime.now() + ); + + // when + aggregationService.handleOrderCompleted(eventId, event); + + // then + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankingCache).addOrderScoresBatch(captor.capture()); + + List capturedItems = captor.getValue(); + assertThat(capturedItems).hasSize(2); + assertThat(capturedItems.get(0).productId()).isEqualTo(productId1); + assertThat(capturedItems.get(0).price()).isEqualTo(25000L); + assertThat(capturedItems.get(0).quantity()).isEqualTo(2); + } + + @Test + @DisplayName("중복 이벤트는 랭킹 점수를 적재하지 않는다") + void duplicateEvent_doesNotAddRankingScore() { + // given + String eventId = "event-liked-duplicate"; + when(eventHandledRepository.existsById(eventId)).thenReturn(true); + + ProductLikedEvent event = new ProductLikedEvent( + MEMBER_ID, PRODUCT_ID, BRAND_ID, LocalDateTime.now() + ); + + // when + aggregationService.handleProductLiked(eventId, event); + + // then + verify(productRankingCache, never()).addLikeScore(anyLong()); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java new file mode 100644 index 000000000..83820f368 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java @@ -0,0 +1,158 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.config.RankingConfig; +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.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SessionCallback; +import org.springframework.data.redis.core.ZSetOperations; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ProductRankingCacheTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ZSetOperations zSetOperations; + + private RankingConfig rankingConfig; + private ProductRankingCache productRankingCache; + + private static final Long PRODUCT_ID = 100L; + + @BeforeEach + void setUp() { + rankingConfig = new RankingConfig(); + rankingConfig.setTtlDays(2); + RankingConfig.Weight weight = new RankingConfig.Weight(); + weight.setView(0.1); + weight.setLike(0.2); + weight.setOrder(0.6); + rankingConfig.setWeight(weight); + + productRankingCache = new ProductRankingCache(redisTemplate, rankingConfig); + } + + private void setupZSetOperations() { + when(redisTemplate.opsForZSet()).thenReturn(zSetOperations); + } + + @Test + @DisplayName("addViewScore - 조회 점수가 가중치 0.1로 증가한다") + void addViewScore_incrementsScoreWithWeight() { + // given + setupZSetOperations(); + + // when + productRankingCache.addViewScore(PRODUCT_ID); + + // then + verify(zSetOperations).incrementScore(anyString(), eq(PRODUCT_ID.toString()), eq(0.1)); + } + + @Test + @DisplayName("addLikeScore - 좋아요 점수가 가중치 0.2로 증가한다") + void addLikeScore_incrementsScoreWithWeight() { + // given + setupZSetOperations(); + + // when + productRankingCache.addLikeScore(PRODUCT_ID); + + // then + verify(zSetOperations).incrementScore(anyString(), eq(PRODUCT_ID.toString()), eq(0.2)); + } + + @Test + @DisplayName("subtractLikeScore - 좋아요 취소 시 점수가 감소한다") + void subtractLikeScore_decrementsScore() { + // given + setupZSetOperations(); + + // when + productRankingCache.subtractLikeScore(PRODUCT_ID); + + // then + verify(zSetOperations).incrementScore(anyString(), eq(PRODUCT_ID.toString()), eq(-0.2)); + } + + @Test + @DisplayName("addOrderScore - 주문 점수에 log 정규화가 적용된다") + void addOrderScore_appliesLogNormalization() { + // given + setupZSetOperations(); + long price = 100000L; + int quantity = 2; + double expectedScore = 0.6 * Math.log1p(price * quantity); + + // when + productRankingCache.addOrderScore(PRODUCT_ID, price, quantity); + + // then + ArgumentCaptor scoreCaptor = ArgumentCaptor.forClass(Double.class); + verify(zSetOperations).incrementScore(anyString(), eq(PRODUCT_ID.toString()), scoreCaptor.capture()); + + assertThat(scoreCaptor.getValue()).isCloseTo(expectedScore, within(0.0001)); + } + + @Test + @DisplayName("addOrderScoresBatch - 여러 주문 아이템이 일괄 처리된다") + void addOrderScoresBatch_processesBatch() { + // given + List orderItems = List.of( + new ProductRankingCache.OrderItemScore(1L, 10000L, 1), + new ProductRankingCache.OrderItemScore(2L, 20000L, 2), + new ProductRankingCache.OrderItemScore(3L, 30000L, 3) + ); + + when(redisTemplate.executePipelined(any(SessionCallback.class))).thenReturn(List.of()); + + // when + productRankingCache.addOrderScoresBatch(orderItems); + + // then + verify(redisTemplate).executePipelined(any(SessionCallback.class)); + } + + @Test + @DisplayName("addOrderScoresBatch - 빈 리스트는 처리하지 않는다") + void addOrderScoresBatch_skipsEmptyList() { + // when + productRankingCache.addOrderScoresBatch(List.of()); + + // then + verify(redisTemplate, never()).executePipelined(any(SessionCallback.class)); + } + + @Test + @DisplayName("TTL이 첫 호출 시에만 설정된다") + void ttl_setOnlyOnFirstCall() { + // given + setupZSetOperations(); + + // when - 같은 날짜에 여러 번 호출 + productRankingCache.addViewScore(PRODUCT_ID); + productRankingCache.addLikeScore(PRODUCT_ID); + productRankingCache.addViewScore(PRODUCT_ID); + + // then - expire는 한 번만 호출됨 + verify(redisTemplate, times(1)).expire(anyString(), eq(2L), eq(TimeUnit.DAYS)); + } + + private static org.assertj.core.data.Offset within(double offset) { + return org.assertj.core.data.Offset.offset(offset); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/kafka/DlqPublisherTest.java b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/kafka/DlqPublisherTest.java index 3988a519d..21611d144 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/kafka/DlqPublisherTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/kafka/DlqPublisherTest.java @@ -3,11 +3,13 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.loopers.domain.dlq.DlqMessage; import com.loopers.infrastructure.persistence.DlqMessageRepository; +import com.loopers.testcontainers.RedisTestContainersConfig; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +20,7 @@ */ @SpringBootTest @ActiveProfiles("test") +@Import(RedisTestContainersConfig.class) @Transactional class DlqPublisherTest {