diff --git a/src/main/java/com/zufar/onlinestore/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/zufar/onlinestore/common/exception/handler/GlobalExceptionHandler.java index 9a457257..381c27e5 100644 --- a/src/main/java/com/zufar/onlinestore/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/zufar/onlinestore/common/exception/handler/GlobalExceptionHandler.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import java.util.stream.Stream; @@ -45,7 +46,7 @@ private List collectErrorMessages(Exception exception) { .getBindingResult() .getAllErrors().stream() .map(DefaultMessageSourceResolvable::getDefaultMessage) - .toList() : List.of(exception.getMessage()); + .toList() : Collections.singletonList(exception.getMessage()); } private String buildErrorDescription(Exception exception) { diff --git a/src/main/java/com/zufar/onlinestore/product/api/SingleProductProvider.java b/src/main/java/com/zufar/onlinestore/product/api/SingleProductProvider.java index abacf1ee..b8f8216a 100644 --- a/src/main/java/com/zufar/onlinestore/product/api/SingleProductProvider.java +++ b/src/main/java/com/zufar/onlinestore/product/api/SingleProductProvider.java @@ -30,4 +30,4 @@ public ProductInfoDto getProductById(final UUID productId) { return new ProductNotFoundException(productId); }); } -} \ No newline at end of file +} diff --git a/src/main/java/com/zufar/onlinestore/product/exception/ProductNotFoundException.java b/src/main/java/com/zufar/onlinestore/product/exception/ProductNotFoundException.java index 892864f7..dd2dc6e1 100644 --- a/src/main/java/com/zufar/onlinestore/product/exception/ProductNotFoundException.java +++ b/src/main/java/com/zufar/onlinestore/product/exception/ProductNotFoundException.java @@ -10,7 +10,7 @@ public class ProductNotFoundException extends RuntimeException { private final UUID productId; public ProductNotFoundException(final UUID productId) { - super(String.format("The product with productId = %s is not found.",productId)); + super(String.format("The product with productId = %s is not found.", productId)); this.productId = productId; } } diff --git a/src/main/java/com/zufar/onlinestore/product/exception/handler/ProductExceptionHandler.java b/src/main/java/com/zufar/onlinestore/product/exception/handler/ProductExceptionHandler.java new file mode 100644 index 00000000..4be74165 --- /dev/null +++ b/src/main/java/com/zufar/onlinestore/product/exception/handler/ProductExceptionHandler.java @@ -0,0 +1,27 @@ +package com.zufar.onlinestore.product.exception.handler; + +import com.zufar.onlinestore.common.exception.handler.GlobalExceptionHandler; +import com.zufar.onlinestore.common.response.ApiResponse; +import com.zufar.onlinestore.product.exception.ProductNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RequiredArgsConstructor +@RestControllerAdvice +@Slf4j +public class ProductExceptionHandler extends GlobalExceptionHandler { + + @ExceptionHandler(ProductNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ApiResponse handleProductNotFoundException(final ProductNotFoundException exception) { + ApiResponse apiResponse = buildResponse(exception, HttpStatus.NOT_FOUND); + log.error("Handle product not found exception: failed: messages: {}, description: {}.", + apiResponse.messages(), apiResponse.description()); + + return apiResponse; + } +} diff --git a/src/test/java/com/zufar/onlinestore/product/endpoint/ProductsEndpointTest.java b/src/test/java/com/zufar/onlinestore/product/endpoint/ProductsEndpointTest.java new file mode 100644 index 00000000..a10ddfa3 --- /dev/null +++ b/src/test/java/com/zufar/onlinestore/product/endpoint/ProductsEndpointTest.java @@ -0,0 +1,181 @@ +package com.zufar.onlinestore.product.endpoint; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zufar.onlinestore.common.response.ApiResponse; +import com.zufar.onlinestore.product.api.ProductApi; +import com.zufar.onlinestore.product.dto.ProductInfoDto; +import com.zufar.onlinestore.product.dto.ProductListWithPaginationInfoDto; +import com.zufar.onlinestore.product.exception.ProductNotFoundException; +import com.zufar.onlinestore.security.jwt.filter.JwtAuthenticationProvider; +import org.instancio.Instancio; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.UUID; + +import static com.zufar.onlinestore.product.endpoint.ProductsEndpoint.PRODUCTS_URL; +import static com.zufar.onlinestore.product.util.ProductStub.buildSampleProducts; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = ProductsEndpoint.class, excludeAutoConfiguration = {SecurityAutoConfiguration.class}) +class ProductsEndpointTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private ProductApi productApi; + + @MockBean + private JwtAuthenticationProvider provider; + + private ProductInfoDto productInfo; + + private ProductListWithPaginationInfoDto productList; + + @BeforeEach + void setUp() { + productInfo = Instancio.create(ProductInfoDto.class); + productList = buildSampleProducts(0, 10, "name", Sort.Direction.ASC); + } + + @Test + void whenGetProductsSortedByNameDescThenReturnProducts() throws Exception { + int page = 0; + int size = 10; + String sortAttribute = "name"; + Sort.Direction sortDirection = Sort.Direction.DESC; + ProductListWithPaginationInfoDto products = buildSampleProducts(page, size, sortAttribute, sortDirection); + + when(productApi.getProducts(page, size, sortAttribute, sortDirection.name())).thenReturn(products); + + mockMvc.perform(get(PRODUCTS_URL) + .param("page", String.valueOf(page)) + .param("size", String.valueOf(size)) + .param("sort_attribute", sortAttribute) + .param("sort_direction", sortDirection.name()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.products[0].name").value("Product E")) + .andExpect(jsonPath("$.totalElements").value(products.totalElements())); + + verify(productApi).getProducts(page, size, sortAttribute, sortDirection.name()); + } + + + @Test + void whenGetProductByIdThenReturn200() throws Exception { + UUID productId = UUID.randomUUID(); + + when(productApi.getProduct(productId)).thenReturn(productInfo); + + mockMvc.perform(get(PRODUCTS_URL + "/{productId}", productId) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(productInfo.id().toString())); + + verify(productApi).getProduct(productId); + } + + @Test + void whenGetProductsThenReturn200() throws Exception { + int page = 1; + int size = 10; + String sortAttribute = "name"; + String defaultDirection = Sort.Direction.ASC.name(); + + when(productApi.getProducts(page, size, sortAttribute, defaultDirection)).thenReturn(productList); + + mockMvc.perform(get(PRODUCTS_URL) + .param("page", String.valueOf(page)) + .param("size", String.valueOf(size)) + .param("sort_attribute", sortAttribute) + .param("sort_direction", defaultDirection) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.products.size()").value(productList.products().size())); + + verify(productApi).getProducts(page, size, sortAttribute, defaultDirection); + } + + @Test + void whenNullProductIdThenReturn404() throws Exception { + UUID productId = UUID.randomUUID(); + String errorDescription = buildErrorDescription( + ProductsEndpoint.class.getName() + ); + + when(productApi.getProduct(productId)).thenThrow(new ProductNotFoundException(productId)); + + MvcResult mvcResult = mockMvc.perform(get(PRODUCTS_URL + "/{productId}", productId)) + .andExpect(status().isNotFound()) + .andReturn(); + + String actualResponse = mvcResult.getResponse().getContentAsString(); + ApiResponse expectedResponse = createExpectedErrorResponse(errorDescription, productId); + String expectedResponseBody = objectMapper.writeValueAsString(expectedResponse); + + assertThat(actualResponse).isEqualTo(expectedResponseBody); + + verify(productApi).getProduct(productId); + } + + @Test + void whenGetProductsSortedByNameAscThenReturnProducts() throws Exception { + int page = 0; + int size = 10; + String sortAttribute = "name"; + Sort.Direction sortDirection = Sort.Direction.ASC; + + when(productApi.getProducts(page, size, sortAttribute, sortDirection.name())).thenReturn(productList); + + mockMvc.perform(get(PRODUCTS_URL) + .param("page", String.valueOf(page)) + .param("size", String.valueOf(size)) + .param("sort_attribute", sortAttribute) + .param("sort_direction", sortDirection.name()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.products[0].name").value("Product A")) + .andExpect(jsonPath("$.totalElements").value(productList.totalElements())); + + verify(productApi).getProducts(page, size, sortAttribute, sortDirection.name()); + } + + private String buildErrorDescription(String className) { + final int problematicCodeLine = 33; + return String.format("Operation was failed in method: %s that belongs to the class: %s. Problematic code line: %d", + "getProductById", className, problematicCodeLine); + } + + private ApiResponse createExpectedErrorResponse(String errorDescription, UUID productId) { + return new ApiResponse<>( + null, + Collections.singletonList(String.format("The product with productId = %s is not found.", productId)), + errorDescription, + HttpStatus.NOT_FOUND.value(), + LocalDateTime.now()); + } +} diff --git a/src/test/java/com/zufar/onlinestore/product/util/ProductStub.java b/src/test/java/com/zufar/onlinestore/product/util/ProductStub.java new file mode 100644 index 00000000..44c9298d --- /dev/null +++ b/src/test/java/com/zufar/onlinestore/product/util/ProductStub.java @@ -0,0 +1,91 @@ +package com.zufar.onlinestore.product.util; + +import com.zufar.onlinestore.product.dto.ProductInfoDto; +import com.zufar.onlinestore.product.dto.ProductListWithPaginationInfoDto; +import org.springframework.data.domain.Sort; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public class ProductStub { + + public static ProductListWithPaginationInfoDto buildSampleProducts(Integer page, Integer size, String sortAttribute, Sort.Direction sortDirection) { + List products = generateSampleProducts(); + List sortedProducts = sortProducts(products, sortAttribute, sortDirection); + + return new ProductListWithPaginationInfoDto(sortedProducts, page, size, (long) sortedProducts.size(), calculateTotalPages(products.size(), size)); + } + + private static List generateSampleProducts() { + List products = new ArrayList<>(); + + products.add(new ProductInfoDto( + UUID.randomUUID(), + "Product A", + "Description for Product A", + BigDecimal.valueOf(10.50), + 20 + )); + + products.add(new ProductInfoDto( + UUID.randomUUID(), + "Product B", + "Description for Product B", + BigDecimal.valueOf(15.75), + 50 + )); + + products.add(new ProductInfoDto( + UUID.randomUUID(), + "Product C", + "Description for Product C", + BigDecimal.valueOf(20.00), + 30 + )); + + products.add(new ProductInfoDto( + UUID.randomUUID(), + "Product D", + "Description for Product D", + BigDecimal.valueOf(5.25), + 40 + )); + + products.add(new ProductInfoDto( + UUID.randomUUID(), + "Product E", + "Description for Product E", + BigDecimal.valueOf(12.00), + 60 + )); + return products; + } + + private static List sortProducts(List products, String sortAttribute, Sort.Direction direction) { + Map> attributeToComparator = Map.of( + "name", Comparator.comparing(ProductInfoDto::name), + "description", Comparator.comparing(ProductInfoDto::description), + "price", Comparator.comparing(ProductInfoDto::price), + "quantity", Comparator.comparing(ProductInfoDto::quantity) + ); + + Comparator comparator = attributeToComparator.getOrDefault(sortAttribute.toLowerCase(), null); + + if (comparator == null) { + throw new IllegalArgumentException("Unsupported sort attribute: " + sortAttribute); + } + + if (direction == Sort.Direction.DESC) { + comparator = comparator.reversed(); + } + return products.stream().sorted(comparator).toList(); + } + + private static Integer calculateTotalPages(Integer totalElements, Integer size) { + return (totalElements + size - 1) / size; + } +}