From 2b7f4df9b91b04828f6d14525b8c93a68792ac3c Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 12:30:38 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Add JUnit 5/Mockito tests for product, ranking, repositories, and API --- .codegen-notes/TESTING_STACK_NOTE.txt | 2 + .../product/ProductFacadeTest.java | 203 ++++++++ .../ranking/RankingFacadeTest.java | 135 ++++++ .../ProductCacheRepositoryImplTest.java | 389 +++++++++++++++ .../product/ProductRepositoryImplTest.java | 453 ++++++++++++++++++ .../ranking/RankingRepositoryImplTest.java | 299 ++++++++++++ .../api/product/ProductResponseTest.java | 325 +++++++++++++ .../api/ranking/RankingRequestTest.java | 186 +++++++ .../api/ranking/RankingV1ControllerTest.java | 142 ++++++ 9 files changed, 2134 insertions(+) create mode 100644 .codegen-notes/TESTING_STACK_NOTE.txt create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheRepositoryImplTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryImplTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingRepositoryImplTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductResponseTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingRequestTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java diff --git a/.codegen-notes/TESTING_STACK_NOTE.txt b/.codegen-notes/TESTING_STACK_NOTE.txt new file mode 100644 index 0000000..df80f03 --- /dev/null +++ b/.codegen-notes/TESTING_STACK_NOTE.txt @@ -0,0 +1,2 @@ +Tests added for RankingRepositoryImpl. +Assumed testing stack: JUnit 5 (Jupiter) + Mockito. Adjust imports if your project differs. \ No newline at end of file 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 0000000..548606f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,203 @@ +package com.loopers.application.product; + +import com.loopers.domain.activity.event.ActivityEvent; +import com.loopers.domain.brand.BrandResult; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductResult; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.ranking.RankingCommand; +import com.loopers.domain.ranking.RankingService; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.BusinessException; +import com.loopers.support.error.CommonErrorType; +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.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.task.TaskExecutor; +import org.springframework.util.concurrent.ListenableFutureTask; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +/** + * Test stack: JUnit 5 (Jupiter) + Mockito + AssertJ. + * These tests validate ProductFacade.getProductDetail focusing on the behaviors introduced in the PR diff: + * - Fetching product detail, brand (nullable), and rank (nullable) + * - Publishing ActivityEvent.View asynchronously only when userName is present and resolves to a user + */ +class ProductFacadeTest { + + private ProductService productService; + private BrandService brandService; + private RankingService rankingService; + private UserService userService; + private TaskExecutor taskExecutor; + private ApplicationEventPublisher eventPublisher; + + private ProductFacade facade; + + @BeforeEach + void setUp() { + productService = mock(ProductService.class); + brandService = mock(BrandService.class); + rankingService = mock(RankingService.class); + userService = mock(UserService.class); + eventPublisher = mock(ApplicationEventPublisher.class); + + // Synchronous TaskExecutor for deterministic testing of async branch + taskExecutor = runnable -> runnable.run(); + + facade = new ProductFacade( + productService, + brandService, + rankingService, + userService, + taskExecutor, + eventPublisher + ); + } + + private static ProductInput.GetProductDetail input(Long productId, String userName) { + // Adapt to actual constructor/factory if different in codebase + return new ProductInput.GetProductDetail(productId, userName); + } + + private static ProductResult.GetProductDetail productDetail(Long productId, Long brandId) { + // Adapt to actual constructor/factory if different in codebase + return new ProductResult.GetProductDetail(productId, "name", brandId, "desc", 1000L); + } + + private static BrandResult.GetBrand brand(Long brandId) { + // Adapt to actual constructor/factory if different in codebase + return new BrandResult.GetBrand(brandId, "brand"); + } + + @Nested + @DisplayName("Happy paths") + class HappyPaths { + @Test + @DisplayName("Returns output with brand and rank when present; publishes view event for valid userName") + void returnsOutputWithBrandAndRank_andPublishesEvent() { + // given + Long productId = 10L; + Long brandId = 7L; + Long expectedRank = 5L; + String userName = "jane"; + + ProductResult.GetProductDetail detail = productDetail(productId, brandId); + when(productService.getProductDetail(productId)).thenReturn(Optional.of(detail)); + when(brandService.getBrand(brandId)).thenReturn(Optional.of(brand(brandId))); + when(rankingService.findRank(new RankingCommand.FindRank(LocalDate.now(), productId))) + .thenReturn(Optional.of(expectedRank)); + User mockUser = mock(User.class); + when(mockUser.getUserId()).thenReturn(99L); + when(userService.getUser(userName)).thenReturn(Optional.of(mockUser)); + + // when + ProductOutput.GetProductDetail out = facade.getProductDetail(input(productId, userName)); + + // then + assertThat(out).isNotNull(); + + ArgumentCaptor evt = ArgumentCaptor.forClass(Object.class); + verify(eventPublisher, times(1)).publishEvent(evt.capture()); + assertThat(evt.getValue()).isInstanceOf(ActivityEvent.View.class); + + // Verify call order through main flow + InOrder inOrder = inOrder(productService, brandService, rankingService); + inOrder.verify(productService).getProductDetail(productId); + inOrder.verify(brandService).getBrand(brandId); + inOrder.verify(rankingService).findRank(any(RankingCommand.FindRank.class)); + } + + @Test + @DisplayName("Returns output when brand and rank are absent (nulls propagated)") + void returnsOutputWhenBrandAndRankAbsent() { + Long productId = 11L; + Long brandId = 77L; + + ProductResult.GetProductDetail detail = productDetail(productId, brandId); + when(productService.getProductDetail(productId)).thenReturn(Optional.of(detail)); + when(brandService.getBrand(brandId)).thenReturn(Optional.empty()); + when(rankingService.findRank(any(RankingCommand.FindRank.class))).thenReturn(Optional.empty()); + + ProductOutput.GetProductDetail out = facade.getProductDetail(input(productId, null)); + + assertThat(out).isNotNull(); + verify(eventPublisher, never()).publishEvent(any()); + } + } + + @Nested + @DisplayName("Guard clauses and edge cases") + class GuardAndEdges { + @Test + @DisplayName("Throws NOT_FOUND when product detail is missing") + void throwsWhenProductMissing() { + Long productId = 404L; + when(productService.getProductDetail(productId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> facade.getProductDetail(input(productId, "user"))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(CommonErrorType.NOT_FOUND.name()); + verifyNoInteractions(brandService, rankingService, eventPublisher, userService); + } + + @Test + @DisplayName("Does not publish event when userName is null") + void noEventWhenUserNameNull() { + Long productId = 12L; + Long brandId = 1L; + + when(productService.getProductDetail(productId)).thenReturn(Optional.of(productDetail(productId, brandId))); + when(brandService.getBrand(brandId)).thenReturn(Optional.of(brand(brandId))); + + ProductOutput.GetProductDetail out = facade.getProductDetail(input(productId, null)); + + assertThat(out).isNotNull(); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("Does not publish event when userName is blank") + void noEventWhenUserNameBlank() { + Long productId = 13L; + Long brandId = 2L; + + when(productService.getProductDetail(productId)).thenReturn(Optional.of(productDetail(productId, brandId))); + when(brandService.getBrand(brandId)).thenReturn(Optional.of(brand(brandId))); + + ProductOutput.GetProductDetail out = facade.getProductDetail(input(productId, " ")); + + assertThat(out).isNotNull(); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("Executes async branch but does not publish when user not found") + void asyncBranchNoUser() { + Long productId = 14L; + Long brandId = 3L; + + when(productService.getProductDetail(productId)).thenReturn(Optional.of(productDetail(productId, brandId))); + when(brandService.getBrand(brandId)).thenReturn(Optional.of(brand(brandId))); + when(userService.getUser("ghost")).thenReturn(Optional.empty()); + + ProductOutput.GetProductDetail out = facade.getProductDetail(input(productId, "ghost")); + + assertThat(out).isNotNull(); + verify(userService, times(1)).getUser("ghost"); + verify(eventPublisher, never()).publishEvent(any()); + } + } +} \ No newline at end of file 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 0000000..fd1d583 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java @@ -0,0 +1,135 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.product.ProductResult; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.ranking.RankingCommand; +import com.loopers.domain.ranking.RankingResult; +import com.loopers.domain.ranking.RankingService; +import com.loopers.support.error.BusinessException; +import com.loopers.support.error.CommonErrorType; +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.mockito.ArgumentCaptor; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Test framework: JUnit Jupiter (JUnit 5) + Mockito. + * These tests validate RankingFacade.searchRankings across happy paths, empty results, and missing product details. + */ +class RankingFacadeTest { + + private RankingService rankingService; + private ProductService productService; + private RankingFacade facade; + + @BeforeEach + void setUp() { + rankingService = mock(RankingService.class); + productService = mock(ProductService.class); + facade = new RankingFacade(rankingService, productService); + } + + @Nested + @DisplayName("searchRankings - command wiring and happy path") + class HappyPath { + + @Test + void returnsOutputFromRanksAndProductDetails_whenItemsPresent() { + // Arrange + LocalDate date = LocalDate.of(2024, 12, 25); + int page = 2, size = 3; + + // Fake rank items with productIds 11, 22, 33 + var rank1 = new RankingResult.SearchRanks.Item(1, 11L); + var rank2 = new RankingResult.SearchRanks.Item(2, 22L); + var rank3 = new RankingResult.SearchRanks.Item(3, 33L); + var ranks = new RankingResult.SearchRanks(date, page, size, 9, List.of(rank1, rank2, rank3)); + + when(rankingService.searchRanks(any(RankingCommand.SearchRanks.class))).thenReturn(ranks); + + var d1 = new ProductResult.GetProductDetail(11L, "p11", "desc11", 100L, 10); + var d2 = new ProductResult.GetProductDetail(22L, "p22", "desc22", 200L, 20); + var d3 = new ProductResult.GetProductDetail(33L, "p33", "desc33", 300L, 30); + + when(productService.getProductDetail(11L)).thenReturn(Optional.of(d1)); + when(productService.getProductDetail(22L)).thenReturn(Optional.of(d2)); + when(productService.getProductDetail(33L)).thenReturn(Optional.of(d3)); + + var input = new RankingInput.SearchRankings(date, page, size); + + // Act + var output = facade.searchRankings(input); + + // Assert - command wiring + ArgumentCaptor captor = ArgumentCaptor.forClass(RankingCommand.SearchRanks.class); + verify(rankingService).searchRanks(captor.capture()); + var sent = captor.getValue(); + assertEquals(date, sent.date()); + assertEquals(page, sent.page()); + assertEquals(size, sent.size()); + + // Assert - product details fetched for each rank + verify(productService).getProductDetail(11L); + verify(productService).getProductDetail(22L); + verify(productService).getProductDetail(33L); + verifyNoMoreInteractions(productService); + + // Basic sanity on output + assertNotNull(output, "Output must not be null"); + } + } + + @Nested + @DisplayName("searchRankings - empty items") + class EmptyItems { + + @Test + void returnsEmptyOutputAndDoesNotQueryProductService_whenNoItems() { + // Arrange + LocalDate date = LocalDate.of(2024, 1, 1); + int page = 0, size = 10; + var ranks = new RankingResult.SearchRanks(date, page, size, 0, List.of()); + when(rankingService.searchRanks(any(RankingCommand.SearchRanks.class))).thenReturn(ranks); + + var input = new RankingInput.SearchRankings(date, page, size); + + // Act + var output = facade.searchRankings(input); + + // Assert + assertNotNull(output, "Output should not be null even when empty"); + verifyNoInteractions(productService); + } + } + + @Nested + @DisplayName("searchRankings - missing product detail") + class MissingProductDetail { + + @Test + void throwsBusinessException_whenProductDetailNotFound() { + // Arrange + LocalDate date = LocalDate.of(2024, 6, 30); + var rank = new RankingResult.SearchRanks.Item(1, 999L); + var ranks = new RankingResult.SearchRanks(date, 0, 1, 1, List.of(rank)); + when(rankingService.searchRanks(any(RankingCommand.SearchRanks.class))).thenReturn(ranks); + when(productService.getProductDetail(999L)).thenReturn(Optional.empty()); + + var input = new RankingInput.SearchRankings(date, 0, 1); + + // Act + Assert + BusinessException ex = assertThrows(BusinessException.class, () -> facade.searchRankings(input)); + assertEquals(CommonErrorType.NOT_FOUND, ex.getErrorType()); + verify(productService).getProductDetail(999L); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheRepositoryImplTest.java new file mode 100644 index 0000000..6ff8f83 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheRepositoryImplTest.java @@ -0,0 +1,389 @@ +package com.loopers.infrastructure.product; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.loopers.config.RedisCacheConfig; +import com.loopers.config.jackson.WrappedJsonMapper; +import com.loopers.domain.product.ProductCacheRepository; +import com.loopers.domain.product.ProductQueryCommand; +import com.loopers.domain.product.ProductQueryResult; +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.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ProductCacheRepositoryImplTest { + + private StringRedisTemplate redisTemplate; + private ValueOperations valueOps; + private HashOperations hashOps; + private WrappedJsonMapper jsonMapper; + + private ProductCacheRepositoryImpl repository; + + @BeforeEach + void setUp() { + redisTemplate = mock(StringRedisTemplate.class); + valueOps = mock(ValueOperations.class); + hashOps = mock(HashOperations.class); + jsonMapper = mock(WrappedJsonMapper.class); + + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(redisTemplate.opsForHash()).thenReturn(hashOps); + + repository = new ProductCacheRepositoryImpl(redisTemplate, jsonMapper); + } + + private ProductQueryCommand.SearchProducts mkSearchCmd(String keyword, Long brandId, String sort, int page, int size) { + // If the real class has a builder/static factory, replace with that. + return new ProductQueryCommand.SearchProducts(keyword, brandId, sort, page, size); + } + + private ProductQueryResult.Products mkProduct(String id, String name) { + // Adapt to the real constructor/factory if needed + return new ProductQueryResult.Products(id, name, null, null, null, null); + } + + private ProductQueryResult.ProductDetail mkDetail(String id) { + // Adapt to the real constructor/factory if needed + return new ProductQueryResult.ProductDetail(id, "name", "brand", "desc", List.of(), List.of()); + } + + @Nested + @DisplayName("searchProducts") + class SearchProductsTests { + + @Test + @DisplayName("returns empty when pageNumber > 2 (no Redis calls)") + void returnsEmptyWhenPageGreaterThanTwo() { + var cmd = mkSearchCmd("k", 1L, "POPULAR", 3, 20); + + Page page = repository.searchProducts(cmd); + + assertTrue(page.isEmpty()); + verify(redisTemplate, never()).opsForValue(); + verifyNoInteractions(valueOps, jsonMapper); + } + + @Test + @DisplayName("returns empty when Redis value missing or blank") + void returnsEmptyWhenRedisMissing() { + var cmd = mkSearchCmd("k", 1L, "POPULAR", 1, 10); + String key = "product.page:" + Objects.hash(cmd.getKeyword(), cmd.getBrandId(), cmd.getSort(), cmd.getPage(), cmd.getSize()); + + when(valueOps.getAndExpire(eq(key), eq(Duration.ofSeconds(1)))).thenReturn(null); + + Page page = repository.searchProducts(cmd); + + assertTrue(page.isEmpty()); + verify(redisTemplate).opsForValue(); + verify(valueOps).getAndExpire(eq(key), eq(Duration.ofSeconds(1))); + verifyNoInteractions(jsonMapper); + } + + @Test + @DisplayName("returns empty when JSON mapper returns null Page") + void returnsEmptyWhenMapperReturnsNull() { + var cmd = mkSearchCmd("k", 1L, "POPULAR", 2, 5); + String key = "product.page:" + Objects.hash(cmd.getKeyword(), cmd.getBrandId(), cmd.getSort(), cmd.getPage(), cmd.getSize()); + + when(valueOps.getAndExpire(eq(key), eq(Duration.ofSeconds(1)))).thenReturn("{json}"); + when(jsonMapper.readValue(eq("{json}"), any(TypeReference.class))).thenReturn(null); + + Page page = repository.searchProducts(cmd); + + assertTrue(page.isEmpty()); + verify(jsonMapper).readValue(eq("{json}"), any(TypeReference.class)); + } + + @Test + @DisplayName("returns cached Page when present") + void returnsCachedPage() { + var cmd = mkSearchCmd("shoe", 2L, "RECENT", 0, 2); + String key = "product.page:" + Objects.hash(cmd.getKeyword(), cmd.getBrandId(), cmd.getSort(), cmd.getPage(), cmd.getSize()); + + var products = List.of(mkProduct("p1", "A"), mkProduct("p2", "B")); + PageImpl cached = new PageImpl<>(products, PageRequest.of(0, 2), 5); + + when(valueOps.getAndExpire(eq(key), eq(Duration.ofSeconds(1)))).thenReturn("{json}"); + when(jsonMapper.readValue(eq("{json}"), any(TypeReference.class))).thenReturn(cached); + + Page page = repository.searchProducts(cmd); + + assertFalse(page.isEmpty()); + assertEquals(2, page.getContent().size()); + assertEquals(5, page.getTotalElements()); + } + + @Test + @DisplayName("handles Redis get exception gracefully and returns empty") + void handlesRedisException() { + var cmd = mkSearchCmd("hat", 3L, "PRICE_ASC", 1, 10); + String key = "product.page:" + Objects.hash(cmd.getKeyword(), cmd.getBrandId(), cmd.getSort(), cmd.getPage(), cmd.getSize()); + + when(valueOps.getAndExpire(eq(key), eq(Duration.ofSeconds(1)))).thenThrow(new RuntimeException("redis down")); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + + Page page = repository.searchProducts(cmd); + + assertTrue(page.isEmpty()); + verify(valueOps).getAndExpire(eq(key), eq(Duration.ofSeconds(1))); + verifyNoInteractions(jsonMapper); + } + } + + @Nested + @DisplayName("saveProducts") + class SaveProductsTests { + + @Test + @DisplayName("does nothing when pageNumber > 2") + void doesNothingWhenPageGreaterThanTwo() { + var cmd = mkSearchCmd("k", 1L, "POPULAR", 5, 20); + Page page = Page.empty(); + + repository.saveProducts(cmd, page); + + verifyNoInteractions(jsonMapper); + verify(redisTemplate, never()).opsForValue(); + verifyNoInteractions(valueOps); + } + + @Test + @DisplayName("stores JSON in Redis with TTL=5s and correct key") + void storesWithTtl5s() { + var cmd = mkSearchCmd("shoe", 9L, "RECENT", 1, 3); + var products = List.of(mkProduct("p1", "A")); + Page page = new PageImpl<>(products, PageRequest.of(1,3), 4); + + String expectedJson = "{\"ok\":true}"; + when(jsonMapper.writeValueAsString(page)).thenReturn(expectedJson); + + String expectedKey = "product.page:" + Objects.hash(cmd.getKeyword(), cmd.getBrandId(), cmd.getSort(), cmd.getPage(), cmd.getSize()); + + repository.saveProducts(cmd, page); + + verify(jsonMapper).writeValueAsString(page); + verify(redisTemplate).opsForValue(); + verify(valueOps).set(eq(expectedKey), eq(expectedJson), eq(Duration.ofSeconds(5))); + } + + @Test + @DisplayName("handles Redis set exception gracefully") + void handlesRedisSetException() { + var cmd = mkSearchCmd("hat", 2L, "POPULAR", 0, 10); + Page page = Page.empty(); + when(jsonMapper.writeValueAsString(page)).thenReturn("{}"); + + String key = "product.page:" + Objects.hash(cmd.getKeyword(), cmd.getBrandId(), cmd.getSort(), cmd.getPage(), cmd.getSize()); + doThrow(new RuntimeException("boom")).when(valueOps).set(eq(key), eq("{}"), eq(Duration.ofSeconds(5))); + + repository.saveProducts(cmd, page); + + verify(valueOps).set(eq(key), eq("{}"), eq(Duration.ofSeconds(5))); + } + } + + @Nested + @DisplayName("findDetail") + class FindDetailTests { + + @Test + @DisplayName("returns empty when hash is empty") + void returnsEmptyWhenNoHash() { + Long id = 101L; + when(hashOps.entries("product.detail:" + id)).thenReturn(Map.of()); + + Optional result = repository.findDetail(id); + + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("returns ProductDetail.EMPTY when __null__ sentinel present") + void returnsEmptySentinel() { + Long id = 202L; + when(hashOps.entries("product.detail:" + id)).thenReturn(Map.of("__null__", "null")); + + Optional result = repository.findDetail(id); + + assertTrue(result.isPresent()); + assertSame(ProductQueryResult.ProductDetail.EMPTY, result.get()); + } + + @Test + @DisplayName("returns empty when mapper returns null") + void returnsEmptyWhenMapperNull() { + Long id = 303L; + when(hashOps.entries("product.detail:" + id)).thenReturn(Map.of("a","b")); + when(jsonMapper.readMap(anyMap(), any(TypeReference.class))).thenReturn(null); + + Optional result = repository.findDetail(id); + + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("returns mapped detail when present") + void returnsMappedDetail() { + Long id = 404L; + var cache = Map.of("id", "404"); + var detail = mkDetail("404"); + + when(hashOps.entries("product.detail:" + id)).thenReturn(cache); + when(jsonMapper.readMap(eq(cache), any(TypeReference.class))).thenReturn(detail); + + Optional result = repository.findDetail(id); + + assertTrue(result.isPresent()); + assertEquals("404", result.get().id()); + } + } + + @Nested + @DisplayName("saveDetail") + class SaveDetailTests { + + @Test + @DisplayName("stores __null__ sentinel with jittered TTL when detail is null") + void storesNullSentinel() { + Long id = 505L; + + try (MockedStatic mocked = Mockito.mockStatic(RedisCacheConfig.class)) { + mocked.when(() -> RedisCacheConfig.jitter(any())).thenReturn(Duration.ofMinutes(30)); + + repository.saveDetail(id, null); + + InOrder inOrder = inOrder(hashOps, redisTemplate); + inOrder.verify(hashOps).putAll("product.detail:" + id, Map.of("__null__", "null")); + verify(redisTemplate).expire(eq("product.detail:" + id), eq(Duration.ofMinutes(30))); + } + } + + @Test + @DisplayName("stores mapped hash with jittered TTL when detail provided") + void storesMappedHash() { + Long id = 606L; + var detail = mkDetail("606"); + var map = Map.of("id","606","name","name"); + + when(jsonMapper.writeValueAsMap(detail)).thenReturn(map); + + try (MockedStatic mocked = Mockito.mockStatic(RedisCacheConfig.class)) { + mocked.when(() -> RedisCacheConfig.jitter(any())).thenReturn(Duration.ofMinutes(28)); + + repository.saveDetail(id, detail); + + verify(hashOps).putAll("product.detail:" + id, map); + verify(redisTemplate).expire(eq("product.detail:" + id), eq(Duration.ofMinutes(28))); + } + } + } +} + +// --- Appended tests below --- +// Note: Using JUnit 5 and Mockito based on repository conventions. +import com.fasterxml.jackson.core.type.TypeReference; +import com.loopers.config.RedisCacheConfig; +import com.loopers.config.jackson.WrappedJsonMapper; +import com.loopers.domain.product.ProductQueryCommand; +import com.loopers.domain.product.ProductQueryResult; +import org.junit.jupiter.api.*; +import org.mockito.InOrder; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ProductCacheRepositoryImpl_AdditionalTests { + + private StringRedisTemplate redisTemplate; + private ValueOperations valueOps; + private HashOperations hashOps; + private WrappedJsonMapper jsonMapper; + private ProductCacheRepositoryImpl repository; + + @BeforeEach + void init() { + redisTemplate = mock(StringRedisTemplate.class); + valueOps = mock(ValueOperations.class); + hashOps = mock(HashOperations.class); + jsonMapper = mock(WrappedJsonMapper.class); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(redisTemplate.opsForHash()).thenReturn(hashOps); + repository = new ProductCacheRepositoryImpl(redisTemplate, jsonMapper); + } + + private ProductQueryCommand.SearchProducts mkCmd(int page, int size) { + return new ProductQueryCommand.SearchProducts("kw", 7L, "POPULAR", page, size); + } + + @Test + void searchProducts_blankStringFromRedis_treatedAsEmpty() { + var cmd = mkCmd(1, 10); + String key = "product.page:" + Objects.hash(cmd.getKeyword(), cmd.getBrandId(), cmd.getSort(), cmd.getPage(), cmd.getSize()); + when(valueOps.getAndExpire(eq(key), eq(Duration.ofSeconds(1)))).thenReturn(" "); + + Page res = repository.searchProducts(cmd); + assertTrue(res.isEmpty()); + verifyNoInteractions(jsonMapper); + } + + @Test + void saveProducts_serializesAndStores() { + var cmd = mkCmd(0, 2); + var page = new PageImpl(List.of(), PageRequest.of(0,2), 0); + when(jsonMapper.writeValueAsString(page)).thenReturn("{\"p\":1}"); + String key = "product.page:" + Objects.hash(cmd.getKeyword(), cmd.getBrandId(), cmd.getSort(), cmd.getPage(), cmd.getSize()); + + repository.saveProducts(cmd, page); + + verify(valueOps).set(eq(key), eq("{\"p\":1}"), eq(Duration.ofSeconds(5))); + } + + @Test + void findDetail_readsAndMaps() { + long id = 999L; + var cache = Map.of("id","999"); + when(hashOps.entries("product.detail:" + id)).thenReturn(cache); + var detail = new ProductQueryResult.ProductDetail("999","n","b","d", List.of(), List.of()); + when(jsonMapper.readMap(eq(cache), any(TypeReference.class))).thenReturn(detail); + + Optional res = repository.findDetail(id); + assertTrue(res.isPresent()); + assertEquals("999", res.get().id()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryImplTest.java new file mode 100644 index 0000000..3761f7e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryImplTest.java @@ -0,0 +1,453 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.*; +import com.loopers.domain.product.attribute.ProductSearchSortType; +import com.loopers.domain.brand.QBrand; +import com.loopers.domain.product.QProduct; +import com.loopers.domain.product.QProductOption; +import com.loopers.domain.product.QProductStock; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; + +import java.util.*; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Testing library and framework: JUnit 5 (Jupiter) + Mockito + AssertJ. + * These tests focus on the repository implementation logic and mapping behavior, + * with heavy mocking of QueryDSL fluent APIs to avoid DB dependencies. + */ +@ExtendWith(MockitoExtension.class) +class ProductRepositoryImplTest { + + @Mock private ProductJpaRepository productRepository; + @Mock private ProductOptionJpaRepository productOptionRepository; + @Mock private StockJpaRepository stockJpaRepository; + @Mock private JPAQueryFactory queryFactory; + + // Query mocks (typed) + @Mock private JPAQuery tupleQuery; + @Mock private JPAQuery countQuery; + @Mock private JPAQuery tupleQuery2; // for secondary method chains + @Mock private JPAQuery tupleQuery3; // for findOptions + + @InjectMocks + private ProductRepositoryImpl sut; + + // Q-classes used in mapping/verifications + private final QProduct p = QProduct.product; + private final QBrand b = QBrand.brand; + private final QProductOption po = QProductOption.productOption; + private final QProductStock ps = QProductStock.productStock; + + @BeforeEach + void setup() { + // Default stubbing for chain operations to be lenient across tests + lenient().when(tupleQuery.from(any())).thenReturn(tupleQuery); + lenient().when(tupleQuery.leftJoin(any(Expression.class))).thenReturn(tupleQuery); + lenient().when(tupleQuery.leftJoin(any())).thenReturn(tupleQuery); + lenient().when(tupleQuery.join(any())).thenReturn(tupleQuery); + lenient().when(tupleQuery.on(any())).thenReturn(tupleQuery); + lenient().when(tupleQuery.where(any())).thenReturn(tupleQuery); + lenient().when(tupleQuery.where(any(), any())).thenReturn(tupleQuery); + lenient().when(tupleQuery.offset(anyLong())).thenReturn(tupleQuery); + lenient().when(tupleQuery.limit(anyLong())).thenReturn(tupleQuery); + lenient().when(tupleQuery.orderBy(any(OrderSpecifier[].class))).thenReturn(tupleQuery); + lenient().when(tupleQuery.orderBy(any(), any())).thenReturn(tupleQuery); + + lenient().when(tupleQuery2.from(any())).thenReturn(tupleQuery2); + lenient().when(tupleQuery2.leftJoin(any())).thenReturn(tupleQuery2); + lenient().when(tupleQuery2.join(any())).thenReturn(tupleQuery2); + lenient().when(tupleQuery2.on(any())).thenReturn(tupleQuery2); + lenient().when(tupleQuery2.where(any())).thenReturn(tupleQuery2); + lenient().when(tupleQuery2.where(any(), any())).thenReturn(tupleQuery2); + lenient().when(tupleQuery2.offset(anyLong())).thenReturn(tupleQuery2); + lenient().when(tupleQuery2.limit(anyLong())).thenReturn(tupleQuery2); + lenient().when(tupleQuery2.orderBy(any(OrderSpecifier[].class))).thenReturn(tupleQuery2); + lenient().when(tupleQuery2.orderBy(any(), any())).thenReturn(tupleQuery2); + + lenient().when(tupleQuery3.from(any())).thenReturn(tupleQuery3); + lenient().when(tupleQuery3.join(any())).thenReturn(tupleQuery3); + lenient().when(tupleQuery3.on(any())).thenReturn(tupleQuery3); + lenient().when(tupleQuery3.where(any())).thenReturn(tupleQuery3); + + lenient().when(countQuery.from(any())).thenReturn(countQuery); + lenient().when(countQuery.leftJoin(any())).thenReturn(countQuery); + lenient().when(countQuery.on(any())).thenReturn(countQuery); + lenient().when(countQuery.where(any())).thenReturn(countQuery); + lenient().when(countQuery.where(any(), any())).thenReturn(countQuery); + } + + // Utility: create a mocked Tuple returning values for given expressions + private Tuple mockTuple(Map, Object> values) { + Tuple t = mock(Tuple.class); + values.forEach((expr, val) -> { + // Use lenient stubbing to avoid UnnecessaryStubbingException + lenient().when(t.get(expr)).thenReturn(val); + }); + return t; + } + + @Nested + @DisplayName("searchProducts") + class SearchProductsTests { + + private void stubSearchSelectChain(Stream rows, long count) { + // select for product rows + when(queryFactory.select(any())).thenReturn(tupleQuery); // 1st call (Tuple) + when(tupleQuery.from(eq(p))).thenReturn(tupleQuery); + when(tupleQuery.leftJoin(b)).thenReturn(tupleQuery); + when(tupleQuery.on(eq(b.id.eq(p.brandId)))).thenReturn(tupleQuery); + when(tupleQuery.where(any(), any())).thenReturn(tupleQuery); + when(tupleQuery.offset(anyLong())).thenReturn(tupleQuery); + when(tupleQuery.limit(anyLong())).thenReturn(tupleQuery); + when(tupleQuery.orderBy(any(), any())).thenReturn(tupleQuery); + when(tupleQuery.stream()).thenReturn(rows); + + // select for count + when(queryFactory.select(eq(p.count()))).thenReturn(countQuery); // 2nd call (Long) + when(countQuery.from(eq(p))).thenReturn(countQuery); + when(countQuery.leftJoin(b)).thenReturn(countQuery); + when(countQuery.on(eq(b.id.eq(p.brandId)))).thenReturn(countQuery); + when(countQuery.where(any(), any())).thenReturn(countQuery); + when(countQuery.fetchOne()).thenReturn(count); + } + + @Test + @DisplayName("returns paged products and maps tuple fields correctly") + void returnsPagedProducts() { + Tuple row1 = mockTuple(Map.of( + p.id, 101L, p.name, "Alpha Tee", p.basePrice, 1999, p.likeCount, 5, p.brandId, 77L, b.name, "BrandA" + )); + Tuple row2 = mockTuple(Map.of( + p.id, 102L, p.name, "Beta Tee", p.basePrice, 2999, p.likeCount, 9, p.brandId, 77L, b.name, "BrandA" + )); + stubSearchSelectChain(Stream.of(row1, row2), 2L); + + ProductQueryCommand.SearchProducts cmd = new ProductQueryCommand.SearchProducts( + "tee", 77L, ProductSearchSortType.POPULAR, 0, 10 + ); + + Page page = sut.searchProducts(cmd); + + assertThat(page.getTotalElements()).isEqualTo(2); + assertThat(page.getContent()).hasSize(2); + assertThat(page.getContent().get(0).id()).isEqualTo(101L); + assertThat(page.getContent().get(0).name()).isEqualTo("Alpha Tee"); + assertThat(page.getContent().get(0).basePrice()).isEqualTo(1999); + assertThat(page.getContent().get(0).likeCount()).isEqualTo(5); + assertThat(page.getContent().get(0).brandId()).isEqualTo(77L); + assertThat(page.getContent().get(0).brandName()).isEqualTo("BrandA"); + } + + @Test + @DisplayName("handles empty keyword and brandId (null filters) without errors") + void handlesNullFilters() { + Tuple row = mockTuple(Map.of( + p.id, 201L, p.name, "Gamma", p.basePrice, 999, p.likeCount, 0, p.brandId, 0L, b.name, null + )); + stubSearchSelectChain(Stream.of(row), 1L); + + ProductQueryCommand.SearchProducts cmd = new ProductQueryCommand.SearchProducts( + "", null, ProductSearchSortType.LATEST, 0, 20 + ); + + Page page = sut.searchProducts(cmd); + + assertThat(page.getContent()).hasSize(1); + assertThat(page.getContent().get(0).name()).isEqualTo("Gamma"); + } + + @Test + @DisplayName("applies sort: LATEST then ID desc tie-breaker") + void appliesSortLatest() { + stubSearchSelectChain(Stream.empty(), 0L); + + ProductQueryCommand.SearchProducts cmd = new ProductQueryCommand.SearchProducts( + null, null, ProductSearchSortType.LATEST, 0, 5 + ); + + sut.searchProducts(cmd); + + @SuppressWarnings("unchecked") + ArgumentCaptor> orderCaptor = ArgumentCaptor.forClass(OrderSpecifier.class); + verify(tupleQuery).orderBy(orderCaptor.capture(), orderCaptor.capture()); + List> specifiers = orderCaptor.getAllValues(); + + assertThat(specifiers).hasSize(2); + // First: createdAt DESC + assertThat(specifiers.get(0).getOrder()).isEqualTo(Order.DESC); + assertThat(specifiers.get(0).getTarget()).isEqualTo(p.createdAt); + // Second: id DESC (tie-breaker) + assertThat(specifiers.get(1).getOrder()).isEqualTo(Order.DESC); + assertThat(specifiers.get(1).getTarget()).isEqualTo(p.id); + } + + @Test + @DisplayName("applies sort: POPULAR then ID desc tie-breaker") + void appliesSortPopular() { + stubSearchSelectChain(Stream.empty(), 0L); + + ProductQueryCommand.SearchProducts cmd = new ProductQueryCommand.SearchProducts( + null, null, ProductSearchSortType.POPULAR, 0, 5 + ); + + sut.searchProducts(cmd); + + @SuppressWarnings("unchecked") + ArgumentCaptor> orderCaptor = ArgumentCaptor.forClass(OrderSpecifier.class); + verify(tupleQuery).orderBy(orderCaptor.capture(), orderCaptor.capture()); + List> specifiers = orderCaptor.getAllValues(); + + assertThat(specifiers.get(0).getOrder()).isEqualTo(Order.DESC); + assertThat(specifiers.get(0).getTarget()).isEqualTo(p.likeCount); + assertThat(specifiers.get(1).getOrder()).isEqualTo(Order.DESC); + assertThat(specifiers.get(1).getTarget()).isEqualTo(p.id); + } + + @Test + @DisplayName("applies sort: CHEAP (ASC basePrice) then ID desc tie-breaker") + void appliesSortCheap() { + stubSearchSelectChain(Stream.empty(), 0L); + + ProductQueryCommand.SearchProducts cmd = new ProductQueryCommand.SearchProducts( + null, null, ProductSearchSortType.CHEAP, 0, 5 + ); + + sut.searchProducts(cmd); + + @SuppressWarnings("unchecked") + ArgumentCaptor> orderCaptor = ArgumentCaptor.forClass(OrderSpecifier.class); + verify(tupleQuery).orderBy(orderCaptor.capture(), orderCaptor.capture()); + List> specifiers = orderCaptor.getAllValues(); + + assertThat(specifiers.get(0).getOrder()).isEqualTo(Order.ASC); + assertThat(specifiers.get(0).getTarget()).isEqualTo(p.basePrice); + assertThat(specifiers.get(1).getOrder()).isEqualTo(Order.DESC); + assertThat(specifiers.get(1).getTarget()).isEqualTo(p.id); + } + } + + @Nested + @DisplayName("findDetail") + class FindDetailTests { + + @Test + @DisplayName("returns empty when no rows found") + void returnsEmptyWhenNoRows() { + when(queryFactory.select(any())).thenReturn(tupleQuery); + when(tupleQuery.from(eq(p))).thenReturn(tupleQuery); + when(tupleQuery.leftJoin(b)).thenReturn(tupleQuery); + when(tupleQuery.leftJoin(po)).thenReturn(tupleQuery); + when(tupleQuery.leftJoin(ps)).thenReturn(tupleQuery); + when(tupleQuery.on(any())).thenReturn(tupleQuery); + when(tupleQuery.where(eq(p.id.eq(999L)))).thenReturn(tupleQuery); + when(tupleQuery.fetch()).thenReturn(Collections.emptyList()); + + Optional result = sut.findDetail(999L); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("maps product detail without options when option id is null") + void mapsDetailWithoutOptions() { + Tuple row = mockTuple(Map.of( + p.id, 11L, p.name, "Solo Product", p.basePrice, 500, p.likeCount, 1, + p.brandId, 7L, b.name, "OneBrand", + po.id, null, ps.quantity, null, po.name, null, po.additionalPrice, null, po.productId, null + )); + when(queryFactory.select(any())).thenReturn(tupleQuery); + when(tupleQuery.from(eq(p))).thenReturn(tupleQuery); + when(tupleQuery.leftJoin(b)).thenReturn(tupleQuery); + when(tupleQuery.leftJoin(po)).thenReturn(tupleQuery); + when(tupleQuery.leftJoin(ps)).thenReturn(tupleQuery); + when(tupleQuery.on(any())).thenReturn(tupleQuery); + when(tupleQuery.where(eq(p.id.eq(11L)))).thenReturn(tupleQuery); + when(tupleQuery.fetch()).thenReturn(List.of(row)); + + Optional result = sut.findDetail(11L); + + assertThat(result).isPresent(); + ProductQueryResult.ProductDetail d = result.get(); + assertThat(d.id()).isEqualTo(11L); + assertThat(d.name()).isEqualTo("Solo Product"); + assertThat(d.options()).isEmpty(); + } + + @Test + @DisplayName("aggregates multiple options for the same product") + void aggregatesOptions() { + Tuple r1 = mockTuple(Map.of( + p.id, 21L, p.name, "Multi", p.basePrice, 1000, p.likeCount, 3, p.brandId, 1L, b.name, "B", + po.id, 201L, po.name, "Red", po.additionalPrice, 100, po.productId, 21L, ps.quantity, 5 + )); + Tuple r2 = mockTuple(Map.of( + p.id, 21L, p.name, "Multi", p.basePrice, 1000, p.likeCount, 3, p.brandId, 1L, b.name, "B", + po.id, 202L, po.name, "Blue", po.additionalPrice, 200, po.productId, 21L, ps.quantity, 0 + )); + when(queryFactory.select(any())).thenReturn(tupleQuery); + when(tupleQuery.from(eq(p))).thenReturn(tupleQuery); + when(tupleQuery.leftJoin(b)).thenReturn(tupleQuery); + when(tupleQuery.leftJoin(po)).thenReturn(tupleQuery); + when(tupleQuery.leftJoin(ps)).thenReturn(tupleQuery); + when(tupleQuery.on(any())).thenReturn(tupleQuery); + when(tupleQuery.where(eq(p.id.eq(21L)))).thenReturn(tupleQuery); + when(tupleQuery.fetch()).thenReturn(List.of(r1, r2)); + + Optional result = sut.findDetail(21L); + + assertThat(result).isPresent(); + ProductQueryResult.ProductDetail d = result.get(); + assertThat(d.options()).hasSize(2); + assertThat(d.options().get(0).id()).isEqualTo(201L); + assertThat(d.options().get(1).id()).isEqualTo(202L); + } + } + + @Nested + @DisplayName("findOptions") + class FindOptionsTests { + + @Test + @DisplayName("returns empty when no items found") + void returnsEmptyWhenNoItems() { + // First select in this method returns a tupleQuery3 + when(queryFactory.select(any())).thenReturn(tupleQuery3); + when(tupleQuery3.from(eq(po))).thenReturn(tupleQuery3); + when(tupleQuery3.join(eq(p))).thenReturn(tupleQuery3); + when(tupleQuery3.join(eq(ps))).thenReturn(tupleQuery3); + when(tupleQuery3.on(any())).thenReturn(tupleQuery3); + when(tupleQuery3.where(any())).thenReturn(tupleQuery3); + when(tupleQuery3.stream()).thenReturn(Stream.empty()); + + Optional result = sut.findOptions(List.of(1L, 2L)); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("maps items with salePrice, quantity, and productId") + void mapsItems() { + // Prepare a dynamic expression key to simulate 'salePrice' + // We can't directly access the internal 'salePrice' NumberExpression, but we can + // make the tuple respond to any NumberExpression by default using doAnswer. + Tuple t1 = mock(Tuple.class); + Tuple t2 = mock(Tuple.class); + + // Explicit fields queried in the method + lenient().when(t1.get(po.id)).thenReturn(301L); + lenient().when(t1.get(ps.quantity)).thenReturn(9); + lenient().when(t1.get(p.id)).thenReturn(41L); + lenient().when(t2.get(po.id)).thenReturn(302L); + lenient().when(t2.get(ps.quantity)).thenReturn(0); + lenient().when(t2.get(p.id)).thenReturn(41L); + + // For the computed salePrice expression, respond with given ints + // Since the exact expression instance is created in SUT, match any Expression subclass and branch on the tuple mock + lenient().when(t1.get(any(Expression.class))).thenAnswer(inv -> { + Expression expr = inv.getArgument(0); + if (expr == po.id || expr == ps.quantity || expr == p.id) return inv.callRealMethod(); + return 1100; // basePrice 1000 + additional 100 hypothetically + }); + lenient().when(t2.get(any(Expression.class))).thenAnswer(inv -> { + Expression expr = inv.getArgument(0); + if (expr == po.id || expr == ps.quantity || expr == p.id) return inv.callRealMethod(); + return 1200; + }); + + // Chain stubbing + when(queryFactory.select(any())).thenReturn(tupleQuery3); + when(tupleQuery3.from(eq(po))).thenReturn(tupleQuery3); + when(tupleQuery3.join(eq(p))).thenReturn(tupleQuery3); + when(tupleQuery3.join(eq(ps))).thenReturn(tupleQuery3); + when(tupleQuery3.on(any())).thenReturn(tupleQuery3); + when(tupleQuery3.where(any())).thenReturn(tupleQuery3); + when(tupleQuery3.stream()).thenReturn(Stream.of(t1, t2)); + + Optional result = sut.findOptions(List.of(301L, 302L)); + + assertThat(result).isPresent(); + ProductQueryResult.ProductOptions opts = result.get(); + assertThat(opts.items()).hasSize(2); + assertThat(opts.items().get(0).id()).isEqualTo(301L); + assertThat(opts.items().get(0).salePrice()).isEqualTo(1100); + assertThat(opts.items().get(0).quantity()).isEqualTo(9); + assertThat(opts.items().get(0).productId()).isEqualTo(41L); + assertThat(opts.items().get(1).salePrice()).isEqualTo(1200); + } + } + + @Nested + @DisplayName("Simple delegations") + class DelegationTests { + @Test + @DisplayName("findProductForUpdate delegates to productRepository.findByIdForUpdate") + void findProductForUpdateDelegates() { + Product product = mock(Product.class); + when(productRepository.findByIdForUpdate(77L)).thenReturn(Optional.of(product)); + + Optional result = sut.findProductForUpdate(77L); + + assertThat(result).contains(product); + verify(productRepository).findByIdForUpdate(77L); + } + + @Test + @DisplayName("findStocksForUpdate delegates to stockJpaRepository.findByProductOptionIdIn") + void findStocksForUpdateDelegates() { + ProductStock s1 = mock(ProductStock.class); + ProductStock s2 = mock(ProductStock.class); + List stocks = List.of(s1, s2); + when(stockJpaRepository.findByProductOptionIdIn(List.of(1L, 2L))).thenReturn(stocks); + + List result = sut.findStocksForUpdate(List.of(1L, 2L)); + + assertThat(result).isEqualTo(stocks); + verify(stockJpaRepository).findByProductOptionIdIn(List.of(1L, 2L)); + } + + @Test + @DisplayName("saveProduct delegates to productRepository.save") + void saveProductDelegates() { + Product input = mock(Product.class); + Product saved = mock(Product.class); + when(productRepository.save(input)).thenReturn(saved); + + Product result = sut.saveProduct(input); + + assertThat(result).isEqualTo(saved); + verify(productRepository).save(input); + } + + @Test + @DisplayName("saveStocks delegates to stockJpaRepository.saveAll") + void saveStocksDelegates() { + ProductStock s1 = mock(ProductStock.class); + List input = List.of(s1); + List saved = List.of(mock(ProductStock.class)); + when(stockJpaRepository.saveAll(input)).thenReturn(saved); + + List result = sut.saveStocks(input); + + assertThat(result).isEqualTo(saved); + verify(stockJpaRepository).saveAll(input); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingRepositoryImplTest.java new file mode 100644 index 0000000..b55a644 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingRepositoryImplTest.java @@ -0,0 +1,299 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.RankingQueryResult; +import com.loopers.support.StringUtils; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests focus on the diff for RankingRepositoryImpl. + * + * Testing library/framework used: + * - JUnit 5 (Jupiter) + * - Mockito (MockitoExtension) + */ +@ExtendWith(MockitoExtension.class) +class RankingRepositoryImplTest { + + @Mock + private StringRedisTemplate stringRedisTemplate; + + @Mock + private ZSetOperations zSet; + + @InjectMocks + private RankingRepositoryImpl repository; + + @BeforeEach + void setUp() { + when(stringRedisTemplate.opsForZSet()).thenReturn(zSet); + } + + @Nested + class FindRank { + + @Test + @DisplayName("findRank returns Optional.of(rank) when member rank exists") + void findRank_returnsRank_whenPresent() { + // given + LocalDate date = LocalDate.of(2025, 1, 15); + Long productId = 42L; + String day = date.format(DateTimeFormatter.BASIC_ISO_DATE); + String expectedKey = "metric.product.all:" + day; + String expectedMember = StringUtils.invert9sComplement(productId.toString()); + + when(zSet.rank(expectedKey, expectedMember)).thenReturn(123L); + + // when + Optional result = repository.findRank(date, productId); + + // then + assertTrue(result.isPresent()); + assertEquals(123L, result.get()); + verify(zSet).rank(expectedKey, expectedMember); + verifyNoMoreInteractions(zSet); + } + + @Test + @DisplayName("findRank returns Optional.empty() when rank is null") + void findRank_returnsEmpty_whenNull() { + // given + LocalDate date = LocalDate.of(2025, 1, 15); + Long productId = 7L; + String day = date.format(DateTimeFormatter.BASIC_ISO_DATE); + String expectedKey = "metric.product.all:" + day; + String expectedMember = StringUtils.invert9sComplement(productId.toString()); + + when(zSet.rank(expectedKey, expectedMember)).thenReturn(null); + + // when + Optional result = repository.findRank(date, productId); + + // then + assertTrue(result.isEmpty()); + verify(zSet).rank(expectedKey, expectedMember); + verifyNoMoreInteractions(zSet); + } + } + + @Nested + class SearchRanks { + + @Test + @DisplayName("searchRanks returns empty Page when Redis returns empty members") + void searchRanks_emptyWhenNoMembers() { + // given + LocalDate date = LocalDate.of(2025, 2, 1); + PageRequest pageableInput = PageRequest.of(1, 3); // input expected 1-based + String day = date.format(DateTimeFormatter.BASIC_ISO_DATE); + String expectedKey = "metric.product.all:" + day; + + when(zSet.reverseRange(eq(expectedKey), anyLong(), anyLong())).thenReturn(Collections.emptySet()); + + // when + Page page = repository.searchRanks(date, pageableInput); + + // then + assertTrue(page.getContent().isEmpty()); + assertEquals(0L, page.getTotalElements()); + + // zCard should not be called when members are empty + verify(zSet, never()).zCard(anyString()); + + // Verify paging math (start=0, end=3) + ArgumentCaptor startCap = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor endCap = ArgumentCaptor.forClass(Long.class); + verify(zSet).reverseRange(eq(expectedKey), startCap.capture(), endCap.capture()); + assertEquals(0L, startCap.getValue()); + assertEquals(3L, endCap.getValue()); + + verifyNoMoreInteractions(zSet); + } + + @Test + @DisplayName("searchRanks returns empty Page when Redis returns null members") + void searchRanks_emptyWhenNullMembers() { + // given + LocalDate date = LocalDate.of(2025, 2, 2); + PageRequest pageableInput = PageRequest.of(1, 2); + String day = date.format(DateTimeFormatter.BASIC_ISO_DATE); + String expectedKey = "metric.product.all:" + day; + + when(zSet.reverseRange(eq(expectedKey), anyLong(), anyLong())).thenReturn(null); + + // when + Page page = repository.searchRanks(date, pageableInput); + + // then + assertTrue(page.getContent().isEmpty()); + assertEquals(0L, page.getTotalElements()); + verify(zSet, never()).zCard(anyString()); + verify(zSet).reverseRange(eq(expectedKey), anyLong(), anyLong()); + verifyNoMoreInteractions(zSet); + } + + @Test + @DisplayName("searchRanks returns mapped content with ranks starting from 1 and preserves order") + void searchRanks_happyPath_firstPage() { + // given + LocalDate date = LocalDate.of(2025, 2, 1); + PageRequest pageableInput = PageRequest.of(1, 3); + + String day = date.format(DateTimeFormatter.BASIC_ISO_DATE); + String expectedKey = "metric.product.all:" + day; + + List originalIds = List.of(101L, 202L, 303L); + LinkedHashSet encoded = new LinkedHashSet<>(); + for (Long id : originalIds) { + encoded.add(StringUtils.invert9sComplement(id.toString())); + } + + when(zSet.reverseRange(expectedKey, 0L, 3L)).thenReturn(encoded); + when(zSet.zCard(expectedKey)).thenReturn(100L); + + // when + Page page = repository.searchRanks(date, pageableInput); + + // then + assertEquals(100L, page.getTotalElements()); + assertEquals(3, page.getContent().size()); + + List ids = page.getContent().stream().map(RankingQueryResult.SearchRanks::productId).toList(); + List ranks = page.getContent().stream().map(RankingQueryResult.SearchRanks::rank).toList(); + + assertIterableEquals(originalIds, ids); + assertIterableEquals(List.of(1L, 2L, 3L), ranks); + + verify(zSet).reverseRange(expectedKey, 0L, 3L); + verify(zSet).zCard(expectedKey); + verifyNoMoreInteractions(zSet); + } + + @Test + @DisplayName("searchRanks computes start/end using adjusted page (2nd page -> start=3, end=6)") + void searchRanks_pagingMath_secondPage() { + // given + LocalDate date = LocalDate.of(2025, 2, 1); + PageRequest pageableInput = PageRequest.of(2, 3); // withPage(1) -> offset=3; end=6 + + String day = date.format(DateTimeFormatter.BASIC_ISO_DATE); + String expectedKey = "metric.product.all:" + day; + + LinkedHashSet encoded = new LinkedHashSet<>(); + List originalIds = List.of(404L, 505L, 606L); + for (Long id : originalIds) { + encoded.add(StringUtils.invert9sComplement(id.toString())); + } + + when(zSet.reverseRange(eq(expectedKey), anyLong(), anyLong())).thenReturn(encoded); + when(zSet.zCard(expectedKey)).thenReturn(12L); + + // when + Page page = repository.searchRanks(date, pageableInput); + + // then + ArgumentCaptor startCap = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor endCap = ArgumentCaptor.forClass(Long.class); + verify(zSet).reverseRange(eq(expectedKey), startCap.capture(), endCap.capture()); + + assertEquals(3L, startCap.getValue()); + assertEquals(6L, endCap.getValue()); + + assertEquals(3, page.getContent().size()); + assertIterableEquals(originalIds, + page.getContent().stream().map(RankingQueryResult.SearchRanks::productId).toList()); + assertIterableEquals(List.of(1L, 2L, 3L), + page.getContent().stream().map(RankingQueryResult.SearchRanks::rank).toList()); + + assertEquals(12L, page.getTotalElements()); + verify(zSet).zCard(expectedKey); + verifyNoMoreInteractions(zSet); + } + + @Test + @DisplayName("searchRanks sets totalElements to 0 when zCard returns null") + void searchRanks_totalElementsZeroWhenZCardNull() { + // given + LocalDate date = LocalDate.of(2025, 2, 1); + PageRequest pageableInput = PageRequest.of(1, 2); + + String day = date.format(DateTimeFormatter.BASIC_ISO_DATE); + String expectedKey = "metric.product.all:" + day; + + LinkedHashSet encoded = new LinkedHashSet<>(); + encoded.add(StringUtils.invert9sComplement("11")); + encoded.add(StringUtils.invert9sComplement("22")); + + when(zSet.reverseRange(expectedKey, 0L, 2L)).thenReturn(encoded); + when(zSet.zCard(expectedKey)).thenReturn(null); + + // when + Page page = repository.searchRanks(date, pageableInput); + + // then + assertEquals(0L, page.getTotalElements()); + assertEquals(2, page.getContent().size()); + assertEquals(11L, page.getContent().get(0).productId()); + assertEquals(22L, page.getContent().get(1).productId()); + assertEquals(1L, page.getContent().get(0).rank()); + assertEquals(2L, page.getContent().get(1).rank()); + + verify(zSet).reverseRange(expectedKey, 0L, 2L); + verify(zSet).zCard(expectedKey); + verifyNoMoreInteractions(zSet); + } + + @Test + @DisplayName("searchRanks throws when pageable is 0-based (withPage(-1) -> IllegalArgumentException)") + void searchRanks_throwsWhenZeroBasedPageProvided() { + // given + LocalDate date = LocalDate.of(2025, 2, 3); + + // when/then + assertThrows(IllegalArgumentException.class, + () -> repository.searchRanks(date, PageRequest.of(0, 3))); + } + + @Test + @DisplayName("searchRanks propagates NumberFormatException for invalid member decoding") + void searchRanks_invalidMember_throwsNfe() { + // given + LocalDate date = LocalDate.of(2025, 2, 4); + PageRequest pageableInput = PageRequest.of(1, 1); + String day = date.format(DateTimeFormatter.BASIC_ISO_DATE); + String expectedKey = "metric.product.all:" + day; + + LinkedHashSet encoded = new LinkedHashSet<>(); + encoded.add("abc"); // not numeric; invert9sComplement will not yield a numeric-only string + + when(zSet.reverseRange(eq(expectedKey), anyLong(), anyLong())).thenReturn(encoded); + + // when/then + assertThrows(NumberFormatException.class, () -> repository.searchRanks(date, pageableInput)); + + // zCard not called due to early failure while mapping members + verify(zSet, never()).zCard(anyString()); + verify(zSet).reverseRange(eq(expectedKey), anyLong(), anyLong()); + verifyNoMoreInteractions(zSet); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductResponseTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductResponseTest.java new file mode 100644 index 0000000..b844a25 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductResponseTest.java @@ -0,0 +1,325 @@ +package com.loopers.interfaces.api.product; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +// If AssertJ is available in the project, uncomment the following and prefer assertThat-style assertions. +// import static org.assertj.core.api.Assertions.assertThat; + +class ProductResponseTest { + + @Nested + @DisplayName("SearchProducts.from") + class SearchProductsMapping { + + @Test + @DisplayName("maps full SearchProducts result with multiple items (happy path)") + void mapsFullSearchProducts() { + // Arrange: build a fake ProductResult.SearchProducts with two items. + FakeProductResultItem item1 = new FakeProductResultItem( + 101L, "Alpha Tee", 1999, 12L, 11L, "BrandA" + ); + FakeProductResultItem item2 = new FakeProductResultItem( + 102L, "Beta Hoodie", 4999, 34L, 22L, "BrandB" + ); + FakeSearchProductsResult input = new FakeSearchProductsResult( + 7, 140L, 2, 20, List.of(item1, item2) + ); + + // Act + ProductResponse.SearchProducts out = ProductResponse.SearchProducts.from(input); + + // Assert basic page info + assertEquals(7, out.getTotalPages()); + assertEquals(140L, out.getTotalItems()); + assertEquals(2, out.getPage()); + assertEquals(20, out.getSize()); + + // Assert items mapped correctly + assertNotNull(out.getItems()); + assertEquals(2, out.getItems().size()); + + ProductResponse.SearchProducts.Item mapped1 = out.getItems().get(0); + assertEquals(101L, mapped1.getProductId()); + assertEquals("Alpha Tee", mapped1.getProductName()); + assertEquals(1999, mapped1.getBasePrice()); + assertEquals(12L, mapped1.getLikeCount()); + assertEquals(11L, mapped1.getBrandId()); + assertEquals("BrandA", mapped1.getBrandName()); + + ProductResponse.SearchProducts.Item mapped2 = out.getItems().get(1); + assertEquals(102L, mapped2.getProductId()); + assertEquals("Beta Hoodie", mapped2.getProductName()); + assertEquals(4999, mapped2.getBasePrice()); + assertEquals(34L, mapped2.getLikeCount()); + assertEquals(22L, mapped2.getBrandId()); + assertEquals("BrandB", mapped2.getBrandName()); + } + + @Test + @DisplayName("handles empty items list without error") + void handlesEmptyItems() { + FakeSearchProductsResult input = new FakeSearchProductsResult( + 0, 0L, 0, 10, List.of() + ); + + ProductResponse.SearchProducts out = ProductResponse.SearchProducts.from(input); + + assertEquals(0, out.getTotalPages()); + assertEquals(0L, out.getTotalItems()); + assertEquals(0, out.getPage()); + assertEquals(10, out.getSize()); + assertNotNull(out.getItems()); + assertTrue(out.getItems().isEmpty()); + } + + @Test + @DisplayName("allows null brand values in items (brandless products)") + void allowsNullBrandValues() { + FakeProductResultItem brandless = new FakeProductResultItem( + 201L, "Gamma Cap", 1299, 0L, null, null + ); + FakeSearchProductsResult input = new FakeSearchProductsResult( + 1, 1L, 0, 10, List.of(brandless) + ); + + ProductResponse.SearchProducts out = ProductResponse.SearchProducts.from(input); + + assertEquals(1, out.getItems().size()); + ProductResponse.SearchProducts.Item mapped = out.getItems().get(0); + assertEquals(201L, mapped.getProductId()); + assertEquals("Gamma Cap", mapped.getProductName()); + assertEquals(1299, mapped.getBasePrice()); + assertEquals(0L, mapped.getLikeCount()); + assertNull(mapped.getBrandId()); + assertNull(mapped.getBrandName()); + } + + @Test + @DisplayName("does not mutate source list (defensive mapping)") + void doesNotMutateSourceList() { + ArrayList srcItems = new ArrayList<>(); + srcItems.add(new FakeProductResultItem(1L, "One", 100, 0L, 1L, "B1")); + FakeSearchProductsResult input = new FakeSearchProductsResult( + 1, 1L, 0, 10, srcItems + ); + + ProductResponse.SearchProducts out = ProductResponse.SearchProducts.from(input); + + // mutate source after mapping + srcItems.clear(); + + // mapped output should remain intact + assertEquals(1, out.getItems().size()); + assertEquals("One", out.getItems().get(0).getProductName()); + } + } + + @Nested + @DisplayName("GetProductDetail.from") + class GetProductDetailMapping { + + @Test + @DisplayName("maps full detail with options and brand info (happy path)") + void mapsFullDetail() { + FakeDetailOption opt1 = new FakeDetailOption(1001L, "Size M", 0, 501L, 50); + FakeDetailOption opt2 = new FakeDetailOption(1002L, "Size L", 200, 501L, 20); + + FakeGetProductDetail input = new FakeGetProductDetail( + 501L, "Omega Jacket", 12999, 77L, + List.of(opt1, opt2), + 901L, "OmegaBrand", "Premium outerwear", 3L + ); + + ProductResponse.GetProductDetail out = ProductResponse.GetProductDetail.from(input); + + assertEquals(501L, out.getProductId()); + assertEquals("Omega Jacket", out.getProductName()); + assertEquals(12999, out.getBasePrice()); + assertEquals(77L, out.getLikeCount()); + assertEquals(901L, out.getBrandId()); + assertEquals("OmegaBrand", out.getBrandName()); + assertEquals("Premium outerwear", out.getBrandDescription()); + assertEquals(3L, out.getRank()); + + assertNotNull(out.getOptions()); + assertEquals(2, out.getOptions().size()); + + ProductResponse.GetProductDetail.Option m = out.getOptions().get(0); + assertEquals(1001L, m.getProductOptionId()); + assertEquals("Size M", m.getProductOptionName()); + assertEquals(0, m.getAdditionalPrice()); + assertEquals(501L, m.getProductId()); + assertEquals(50, m.getStockQuantity()); + + ProductResponse.GetProductDetail.Option l = out.getOptions().get(1); + assertEquals(1002L, l.getProductOptionId()); + assertEquals("Size L", l.getProductOptionName()); + assertEquals(200, l.getAdditionalPrice()); + assertEquals(501L, l.getProductId()); + assertEquals(20, l.getStockQuantity()); + } + + @Test + @DisplayName("handles empty options and null brand/rank fields") + void handlesEmptyOptionsAndNulls() { + FakeGetProductDetail input = new FakeGetProductDetail( + 777L, "Nameless", 0, 0L, + List.of(), // empty options + null, null, null, null // brandId, brandName, brandDescription, rank + ); + + ProductResponse.GetProductDetail out = ProductResponse.GetProductDetail.from(input); + + assertEquals(777L, out.getProductId()); + assertEquals("Nameless", out.getProductName()); + assertEquals(0, out.getBasePrice()); + assertEquals(0L, out.getLikeCount()); + + assertTrue(out.getOptions().isEmpty()); + assertNull(out.getBrandId()); + assertNull(out.getBrandName()); + assertNull(out.getBrandDescription()); + assertNull(out.getRank()); + } + + @Test + @DisplayName("does not mutate option list after mapping") + void doesNotMutateOptionsAfterMapping() { + ArrayList options = new ArrayList<>(); + options.add(new FakeDetailOption(1L, "Opt", 0, 9L, 9)); + + FakeGetProductDetail input = new FakeGetProductDetail( + 9L, "P", 1, 1L, options, null, null, null, null + ); + + ProductResponse.GetProductDetail out = ProductResponse.GetProductDetail.from(input); + + options.clear(); // mutate source after mapping + + assertEquals(1, out.getOptions().size()); + assertEquals("Opt", out.getOptions().get(0).getProductOptionName()); + } + } + + // --------------------------------------------------------------------------------------------- + // Minimal fakes to simulate the getters used by ProductResponse.from(...) without depending on + // the real domain/application classes. These are simple POJOs providing the required getters. + // This keeps tests isolated and avoids brittle coupling to builder constructors elsewhere. + + // Fakes for ProductResult.SearchProducts and its item "content" + private static final class FakeProductResultItem { + private final Long productId; + private final String productName; + private final Integer basePrice; + private final Long likeCount; + private final Long brandId; + private final String brandName; + + FakeProductResultItem(Long productId, String productName, Integer basePrice, Long likeCount, + Long brandId, String brandName) { + this.productId = productId; + this.productName = productName; + this.basePrice = basePrice; + this.likeCount = likeCount; + this.brandId = brandId; + this.brandName = brandName; + } + + Long getProductId() { return productId; } + String getProductName() { return productName; } + Integer getBasePrice() { return basePrice; } + Long getLikeCount() { return likeCount; } + Long getBrandId() { return brandId; } + String getBrandName() { return brandName; } + } + + private static final class FakeSearchProductsResult implements com.loopers.domain.product.ProductResult.SearchProducts { + private final Integer totalPages; + private final Long totalItems; + private final Integer page; + private final Integer size; + private final List items; + + FakeSearchProductsResult(Integer totalPages, Long totalItems, Integer page, Integer size, + List items) { + this.totalPages = totalPages; + this.totalItems = totalItems; + this.page = page; + this.size = size; + this.items = items; + } + + public Integer getTotalPages() { return totalPages; } + public Long getTotalItems() { return totalItems; } + public Integer getPage() { return page; } + public Integer getSize() { return size; } + public List getItems() { return items; } + } + + // Fakes for ProductOutput.GetProductDetail and its option + private static final class FakeDetailOption { + private final Long productOptionId; + private final String productOptionName; + private final Integer additionalPrice; + private final Long productId; + private final Integer stockQuantity; + + FakeDetailOption(Long productOptionId, String productOptionName, Integer additionalPrice, + Long productId, Integer stockQuantity) { + this.productOptionId = productOptionId; + this.productOptionName = productOptionName; + this.additionalPrice = additionalPrice; + this.productId = productId; + this.stockQuantity = stockQuantity; + } + + Long getProductOptionId() { return productOptionId; } + String getProductOptionName() { return productOptionName; } + Integer getAdditionalPrice() { return additionalPrice; } + Long getProductId() { return productId; } + Integer getStockQuantity() { return stockQuantity; } + } + + private static final class FakeGetProductDetail implements com.loopers.application.product.ProductOutput.GetProductDetail { + private final Long productId; + private final String productName; + private final Integer basePrice; + private final Long likeCount; + private final List options; + private final Long brandId; + private final String brandName; + private final String brandDescription; + private final Long rank; + + FakeGetProductDetail(Long productId, String productName, Integer basePrice, Long likeCount, + List options, + Long brandId, String brandName, String brandDescription, Long rank) { + this.productId = productId; + this.productName = productName; + this.basePrice = basePrice; + this.likeCount = likeCount; + this.options = options; + this.brandId = brandId; + this.brandName = brandName; + this.brandDescription = brandDescription; + this.rank = rank; + } + + public Long getProductId() { return productId; } + public String getProductName() { return productName; } + public Integer getBasePrice() { return basePrice; } + public Long getLikeCount() { return likeCount; } + public List getOptions() { return options; } + public Long getBrandId() { return brandId; } + public String getBrandName() { return brandName; } + public String getBrandDescription() { return brandDescription; } + public Long getRank() { return rank; } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingRequestTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingRequestTest.java new file mode 100644 index 0000000..d99b7ad --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingRequestTest.java @@ -0,0 +1,186 @@ +package com.loopers.interfaces.api.ranking; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import jakarta.validation.ConstraintViolation; + +import java.time.LocalDate; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class RankingRequestTest { + + private static Validator validator; + + @BeforeAll + static void setUpValidator() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + assertNotNull(validator, "Validator should be initialized"); + } + + @Nested + @DisplayName("SearchRankings validation") + class SearchRankingsValidation { + + @Test + @DisplayName("valid when date is null, page and size positive") + void validWhenDateNullAndPageSizePositive() { + RankingRequest.SearchRankings req = RankingRequest.SearchRankings.builder() + .date(null) // @PastOrPresent allows null + .page(1) + .size(10) + .build(); + + Set> violations = validator.validate(req); + assertTrue(violations.isEmpty(), "Expected no violations when date is null and page/size are positive"); + } + + @Test + @DisplayName("valid when date is today (present)") + void validWhenDateIsToday() { + RankingRequest.SearchRankings req = RankingRequest.SearchRankings.builder() + .date(LocalDate.now()) + .page(2) + .size(25) + .build(); + + Set> violations = validator.validate(req); + assertTrue(violations.isEmpty(), "Expected no violations for present date"); + } + + @Test + @DisplayName("valid when date is in the past") + void validWhenDateInPast() { + RankingRequest.SearchRankings req = RankingRequest.SearchRankings.builder() + .date(LocalDate.now().minusDays(7)) + .page(3) + .size(50) + .build(); + + Set> violations = validator.validate(req); + assertTrue(violations.isEmpty(), "Expected no violations for past date"); + } + + @Test + @DisplayName("violation when date is in the future") + void violationWhenDateInFuture() { + RankingRequest.SearchRankings req = RankingRequest.SearchRankings.builder() + .date(LocalDate.now().plusDays(1)) + .page(1) + .size(10) + .build(); + + Set> violations = validator.validate(req); + assertFalse(violations.isEmpty(), "Expected violations for future date"); + assertTrue(violations.stream().anyMatch(v -> "date".equals(v.getPropertyPath().toString())), + "Expected violation on 'date' field"); + } + + @Test + @DisplayName("violations when page is null and size is valid") + void violationsWhenPageNull() { + RankingRequest.SearchRankings req = RankingRequest.SearchRankings.builder() + .date(LocalDate.now()) + .page(null) // @NotNull + .size(10) + .build(); + + Set> violations = validator.validate(req); + assertFalse(violations.isEmpty(), "Expected violations for null page"); + assertTrue(violations.stream().anyMatch(v -> "page".equals(v.getPropertyPath().toString())), + "Expected violation on 'page' field"); + } + + @Test + @DisplayName("violations when size is null and page is valid") + void violationsWhenSizeNull() { + RankingRequest.SearchRankings req = RankingRequest.SearchRankings.builder() + .date(LocalDate.now()) + .page(1) + .size(null) // @NotNull + .build(); + + Set> violations = validator.validate(req); + assertFalse(violations.isEmpty(), "Expected violations for null size"); + assertTrue(violations.stream().anyMatch(v -> "size".equals(v.getPropertyPath().toString())), + "Expected violation on 'size' field"); + } + + @Test + @DisplayName("violations when page is zero or negative") + void violationsWhenPageNonPositive() { + RankingRequest.SearchRankings zero = RankingRequest.SearchRankings.builder() + .date(LocalDate.now()) + .page(0) // @Positive forbids 0 + .size(10) + .build(); + + RankingRequest.SearchRankings negative = RankingRequest.SearchRankings.builder() + .date(LocalDate.now()) + .page(-5) + .size(10) + .build(); + + Set> vZero = validator.validate(zero); + Set> vNeg = validator.validate(negative); + + assertFalse(vZero.isEmpty(), "Expected violations for page=0"); + assertTrue(vZero.stream().anyMatch(v -> "page".equals(v.getPropertyPath().toString())), + "Expected violation on 'page' for zero"); + + assertFalse(vNeg.isEmpty(), "Expected violations for page<0"); + assertTrue(vNeg.stream().anyMatch(v -> "page".equals(v.getPropertyPath().toString())), + "Expected violation on 'page' for negative"); + } + + @Test + @DisplayName("violations when size is zero or negative") + void violationsWhenSizeNonPositive() { + RankingRequest.SearchRankings zero = RankingRequest.SearchRankings.builder() + .date(LocalDate.now()) + .page(1) + .size(0) + .build(); + + RankingRequest.SearchRankings negative = RankingRequest.SearchRankings.builder() + .date(LocalDate.now()) + .page(1) + .size(-10) + .build(); + + Set> vZero = validator.validate(zero); + Set> vNeg = validator.validate(negative); + + assertFalse(vZero.isEmpty(), "Expected violations for size=0"); + assertTrue(vZero.stream().anyMatch(v -> "size".equals(v.getPropertyPath().toString())), + "Expected violation on 'size' for zero"); + + assertFalse(vNeg.isEmpty(), "Expected violations for size<0"); + assertTrue(vNeg.stream().anyMatch(v -> "size".equals(v.getPropertyPath().toString())), + "Expected violation on 'size' for negative"); + } + + @Test + @DisplayName("builder sets values and getters return them") + void builderAndGetters() { + LocalDate d = LocalDate.now().minusDays(2); + RankingRequest.SearchRankings req = RankingRequest.SearchRankings.builder() + .date(d) + .page(4) + .size(40) + .build(); + + assertEquals(d, req.getDate(), "Date getter should return builder-provided date"); + assertEquals(4, req.getPage(), "Page getter should return builder-provided page"); + assertEquals(40, req.getSize(), "Size getter should return builder-provided size"); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java new file mode 100644 index 0000000..5d0dfc5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java @@ -0,0 +1,142 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingFacade; +import com.loopers.application.ranking.RankingInput; +import com.loopers.application.ranking.RankingOutput; +import com.loopers.interfaces.api.ApiResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Testing with: + * - JUnit 5 (org.junit.jupiter) + * - Spring Boot Test slice (@WebMvcTest) with MockMvc + * - Mockito for mocking collaborators + * + * Focus: /api/v1/rankings GET endpoint behavior and request param mapping. + */ +@WebMvcTest(RankingV1Controller.class) +class RankingV1ControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private RankingFacade rankingFacade; + + @Nested + @DisplayName("GET /api/v1/rankings - searchRankings") + class SearchRankings { + + @Test + @DisplayName("returns 200 with valid params and delegates to facade with mapped input") + void ok_withValidParams_callsFacadeWithInput() throws Exception { + RankingOutput.SearchRankings mockOutput = Mockito.mock(RankingOutput.SearchRankings.class); + when(rankingFacade.searchRankings(any(RankingInput.SearchRankings.class))).thenReturn(mockOutput); + + mockMvc.perform(get("/api/v1/rankings") + .param("date", "2025-09-10") + .param("page", "0") + .param("size", "20") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RankingInput.SearchRankings.class); + verify(rankingFacade, times(1)).searchRankings(captor.capture()); + + RankingInput.SearchRankings captured = captor.getValue(); + // We cannot rely on getters if not present; assert via toString or equals if implemented. + assertThat(captured).isNotNull(); + } + + @Test + @DisplayName("applies default paging when page/size missing (if binding provides defaults)") + void ok_missingPagingParams_usesDefaults() throws Exception { + RankingOutput.SearchRankings mockOutput = Mockito.mock(RankingOutput.SearchRankings.class); + when(rankingFacade.searchRankings(any(RankingInput.SearchRankings.class))).thenReturn(mockOutput); + + mockMvc.perform(get("/api/v1/rankings") + .param("date", "2025-09-10") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + verify(rankingFacade, times(1)).searchRankings(any(RankingInput.SearchRankings.class)); + } + + @Test + @DisplayName("handles invalid numeric params gracefully (bad request for non-numeric page/size)") + void badRequest_whenNonNumericPaging() throws Exception { + mockMvc.perform(get("/api/v1/rankings") + .param("date", "2025-09-10") + .param("page", "abc") + .param("size", "xyz") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is4xxClientError()); + verify(rankingFacade, never()).searchRankings(any()); + } + + @Test + @DisplayName("handles missing required date parameter (if required by binder)") + void badRequest_whenMissingDate() throws Exception { + mockMvc.perform(get("/api/v1/rankings") + .param("page", "0") + .param("size", "10") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is4xxClientError()); + verify(rankingFacade, never()).searchRankings(any()); + } + + @Test + @DisplayName("propagates facade errors as 5xx by default") + void serverError_whenFacadeThrows() throws Exception { + when(rankingFacade.searchRankings(any(RankingInput.SearchRankings.class))) + .thenThrow(new RuntimeException("boom")); + + mockMvc.perform(get("/api/v1/rankings") + .param("date", "2025-09-10") + .param("page", "1") + .param("size", "10") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is5xxServerError()); + } + + @Test + @DisplayName("accepts boundary values for paging") + void ok_boundaryPagingValues() throws Exception { + RankingOutput.SearchRankings mockOutput = Mockito.mock(RankingOutput.SearchRankings.class); + when(rankingFacade.searchRankings(any(RankingInput.SearchRankings.class))).thenReturn(mockOutput); + + // page=0, size=1 minimal + mockMvc.perform(get("/api/v1/rankings") + .param("date", "2025-09-10") + .param("page", "0") + .param("size", "1") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + // large size to test upper bounds behavior (controller should still call facade) + mockMvc.perform(get("/api/v1/rankings") + .param("date", "2025-09-10") + .param("page", "2") + .param("size", "200") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + verify(rankingFacade, times(2)).searchRankings(any(RankingInput.SearchRankings.class)); + } + } +} \ No newline at end of file