diff --git a/api/pom.xml b/api/pom.xml index a48b97e..39da51f 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -84,6 +84,19 @@ true + + org.mapstruct + mapstruct + 1.6.3 + + + + org.mapstruct + mapstruct-processor + 1.6.3 + provided + + junit junit @@ -97,6 +110,24 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.30 + + + org.mapstruct + mapstruct-processor + 1.6.3 + + + + \ No newline at end of file diff --git a/api/src/main/java/com/orderflow/ecommerce/controllers/CategoryController.java b/api/src/main/java/com/orderflow/ecommerce/controllers/CategoryController.java index 8d02ed3..eeb6df1 100644 --- a/api/src/main/java/com/orderflow/ecommerce/controllers/CategoryController.java +++ b/api/src/main/java/com/orderflow/ecommerce/controllers/CategoryController.java @@ -1,59 +1,62 @@ package com.orderflow.ecommerce.controllers; import com.orderflow.ecommerce.controllers.docs.CategoryControllerDocs; -import com.orderflow.ecommerce.entities.Category; -import com.orderflow.ecommerce.repositories.CategoryRepository; +import com.orderflow.ecommerce.dtos.CategoryRequest; +import com.orderflow.ecommerce.dtos.CategoryResponse; +import com.orderflow.ecommerce.services.CategoryService; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.List; -import java.util.NoSuchElementException; - @RestController @RequestMapping(value = "/categories") public class CategoryController implements CategoryControllerDocs { @Autowired - private CategoryRepository repository; + private CategoryService categoryService; @Override @GetMapping - public ResponseEntity> findAll() { - return ResponseEntity.ok().body(repository.findAll()); + public ResponseEntity> findAll( + @RequestParam(required = false) String name, + Pageable pageable + ) { + if (name != null && !name.isBlank()) { + return ResponseEntity.ok(categoryService.findByName(name, pageable)); + } + return ResponseEntity.ok(categoryService.findAll(pageable)); } @Override @GetMapping(value = "/{id}") - public ResponseEntity findById(@PathVariable Long id) { - Category obj = repository.findById(id) - .orElseThrow(() -> new NoSuchElementException("Categoria não encontrada com o ID: " + id)); - return ResponseEntity.ok().body(obj); + public ResponseEntity findById(@PathVariable Long id) { + return ResponseEntity.ok(categoryService.findById(id)); } @Override @PostMapping - public ResponseEntity insert(@Valid @RequestBody Category obj) { - return ResponseEntity.ok().body(repository.save(obj)); + public ResponseEntity create(@Valid @RequestBody CategoryRequest request) { + CategoryResponse response = categoryService.create(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); } @Override - @DeleteMapping(value = "/{id}") - public ResponseEntity delete(@PathVariable Long id) { - repository.deleteById(id); - return ResponseEntity.noContent().build(); + @PutMapping(value = "/{id}") + public ResponseEntity update( + @PathVariable Long id, + @Valid @RequestBody CategoryRequest request + ) { + return ResponseEntity.ok(categoryService.update(id, request)); } @Override - @PutMapping(value = "/{id}") - public ResponseEntity update( - @PathVariable Long id, - @Valid @RequestBody Category obj - ) { - Category entity = repository.findById(id) - .orElseThrow(() -> new NoSuchElementException("Categoria não encontrada para atualizar")); - entity.setName(obj.getName()); - return ResponseEntity.ok().body(repository.save(entity)); + @DeleteMapping(value = "/{id}") + public ResponseEntity delete(@PathVariable Long id) { + categoryService.delete(id); + return ResponseEntity.noContent().build(); } } diff --git a/api/src/main/java/com/orderflow/ecommerce/controllers/ProductController.java b/api/src/main/java/com/orderflow/ecommerce/controllers/ProductController.java index d0ad60f..23308f8 100644 --- a/api/src/main/java/com/orderflow/ecommerce/controllers/ProductController.java +++ b/api/src/main/java/com/orderflow/ecommerce/controllers/ProductController.java @@ -1,10 +1,17 @@ package com.orderflow.ecommerce.controllers; import com.orderflow.ecommerce.controllers.docs.ProductControllerDocs; +import com.orderflow.ecommerce.dtos.ProductFilter; +import com.orderflow.ecommerce.dtos.ProductRequest; +import com.orderflow.ecommerce.dtos.ProductResponse; import com.orderflow.ecommerce.entities.Product; import com.orderflow.ecommerce.repositories.ProductRepository; +import com.orderflow.ecommerce.services.ProductService; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -16,47 +23,43 @@ public class ProductController implements ProductControllerDocs { @Autowired - private ProductRepository repository; + private ProductService productService; @Override @GetMapping - public ResponseEntity> findAll() { - return ResponseEntity.ok().body(repository.findAll()); + public ResponseEntity> findAll( + @ModelAttribute ProductFilter filter, + Pageable pageable + ) { + return ResponseEntity.ok(productService.findAll(filter, pageable)); } @Override @GetMapping(value = "/{id}") - public ResponseEntity findById(@PathVariable Long id) { - Product obj = repository.findById(id) - .orElseThrow(() -> new NoSuchElementException("Produto não encontrado")); - return ResponseEntity.ok().body(obj); + public ResponseEntity findById(@PathVariable Long id) { + return ResponseEntity.ok(productService.findById(id)); } @Override @PostMapping - public ResponseEntity insert(@Valid @RequestBody Product obj) { - return ResponseEntity.ok().body(repository.save(obj)); + public ResponseEntity create(@Valid @RequestBody ProductRequest request) { + ProductResponse response = productService.create(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); } @Override - @DeleteMapping(value = "/{id}") - public ResponseEntity delete(@PathVariable Long id) { - repository.deleteById(id); - return ResponseEntity.noContent().build(); + @PutMapping(value = "/{id}") + public ResponseEntity update( + @PathVariable Long id, + @Valid @RequestBody ProductRequest request + ) { + return ResponseEntity.ok(productService.update(request, id)); } @Override - @PutMapping(value = "/{id}") - public ResponseEntity update(@PathVariable Long id, @Valid @RequestBody Product obj) { - Product entity = repository.findById(id) - .orElseThrow(() -> new NoSuchElementException("Produto não encontrado para atualizar")); - - entity.setName(obj.getName()); - entity.setDescription(obj.getDescription()); - entity.setPrice(obj.getPrice()); - entity.setStockQuantity(obj.getStockQuantity()); - entity.setCategory(obj.getCategory()); - - return ResponseEntity.ok().body(repository.save(entity)); + @DeleteMapping(value = "/{id}") + public ResponseEntity delete(@PathVariable Long id) { + productService.delete(id); + return ResponseEntity.noContent().build(); } } diff --git a/api/src/main/java/com/orderflow/ecommerce/controllers/docs/CategoryControllerDocs.java b/api/src/main/java/com/orderflow/ecommerce/controllers/docs/CategoryControllerDocs.java index f79fabc..802df5e 100644 --- a/api/src/main/java/com/orderflow/ecommerce/controllers/docs/CategoryControllerDocs.java +++ b/api/src/main/java/com/orderflow/ecommerce/controllers/docs/CategoryControllerDocs.java @@ -1,7 +1,8 @@ package com.orderflow.ecommerce.controllers.docs; +import com.orderflow.ecommerce.dtos.CategoryRequest; +import com.orderflow.ecommerce.dtos.CategoryResponse; import com.orderflow.ecommerce.dtos.ErrorResponse; -import com.orderflow.ecommerce.entities.Category; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -10,7 +11,10 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestParam; import java.util.List; @@ -18,17 +22,26 @@ public interface CategoryControllerDocs { @Operation( - summary = "Lista todas as categorias", - description = "Retorna uma lista de todas as categorias existentes no sistema.", - responses = { - @ApiResponse( - responseCode = "200", - description = "Lista obtida com sucesso", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = Category.class))) - ) - } + summary = "Lista todas as categorias", + description = "Retorna uma lista paginada de todas as categorias. Filtra por nome se o parâmetro 'name' for informado.", + parameters = { + @Parameter(name = "name", description = "Filtro opcional por nome da categoria", required = false, example = "Eletrônicos"), + @Parameter(name = "page", description = "Número da página (começa em 0)", example = "0"), + @Parameter(name = "size", description = "Quantidade de itens por página", example = "10"), + @Parameter(name = "sort", description = "Campo e direção de ordenação", example = "name,asc") + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "Lista obtida com sucesso", + content = @Content(schema = @Schema(implementation = CategoryResponse.class)) + ) + } ) - ResponseEntity> findAll(); + ResponseEntity> findAll( + @Parameter(hidden = true) @RequestParam(required = false) String name, + @Parameter(hidden = true) Pageable pageable + ); @Operation( summary = "Obtém categoria por id", @@ -45,28 +58,28 @@ public interface CategoryControllerDocs { @ApiResponse( responseCode = "200", description = "Categoria encontrada", - content = @Content(schema = @Schema(implementation = Category.class))), + content = @Content(schema = @Schema(implementation = CategoryResponse.class))), @ApiResponse( responseCode = "404", description = "Categoria inexistente", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) } ) - ResponseEntity findById(Long id); + ResponseEntity findById(Long id); @Operation( summary = "Cria uma categoria", - description = "Insere uma nova categoria no sistema. O campo 'id' deve ser omitido no envio.", + description = "Insere uma nova categoria no sistema.", requestBody = @RequestBody( description = "Dados da categoria a ser criada", required = true, - content = @Content(schema = @Schema(implementation = Category.class)) + content = @Content(schema = @Schema(implementation = CategoryRequest.class)) ), responses = { @ApiResponse( - responseCode = "200", - description = "Categoria criada e persistida com sucesso", - content = @Content(schema = @Schema(implementation = Category.class)) + responseCode = "201", + description = "Categoria criada com sucesso", + content = @Content(schema = @Schema(implementation = CategoryResponse.class)) ), @ApiResponse( responseCode = "400", @@ -75,7 +88,40 @@ public interface CategoryControllerDocs { ) } ) - ResponseEntity insert(Category obj); + ResponseEntity create(CategoryRequest request); + + @Operation( + summary = "Atualiza o nome de uma categoria", + description = "Atualiza os dados de uma categoria existente com base no ID fornecido", + parameters = { + @Parameter( + name = "id", + description = "Identificador numérico da categoria", + required = true, + example = "1" + ) + }, + requestBody = @RequestBody( + description = "Novos dados para atualização da categoria", + required = true, + content = @Content(schema = @Schema(implementation = CategoryRequest.class)) + ), + responses = { + @ApiResponse( + responseCode = "200", + description = "Categoria atualizada com sucesso", + content = @Content(schema = @Schema(implementation = CategoryResponse.class))), + @ApiResponse( + responseCode = "400", + description = "Corpo inválido ou falha de validação nos dados enviados", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "404", + description = "Categoria inexistente ou não encontrada para o ID informado", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + } + ) + ResponseEntity update(Long id, CategoryRequest request); @Operation( summary = "Remove categoria por id", @@ -91,43 +137,14 @@ public interface CategoryControllerDocs { responses = { @ApiResponse( responseCode = "204", - description = "Exclusão processada (idempotente se o registro não existir)", + description = "Categoria removida com sucesso", content = @Content - ) - } - ) - ResponseEntity delete(Long id); - - @Operation( - summary = "Atualiza o nome de uma categoria", - description = "Atualiza os dados de uma categoria existente com base no ID fornecido", - parameters = { - @Parameter( - name = "id", - description = "Identificador numérico da categoria", - required = true, - example = "1" - ) - }, - requestBody = @RequestBody( - description = "Novos dados para atualização da categoria", - required = true, - content = @Content(schema = @Schema(implementation = Category.class)) - ), - responses = { - @ApiResponse( - responseCode = "200", - description = "Categoria atualizada com sucesso", - content = @Content(schema = @Schema(implementation = Category.class))), - @ApiResponse( - responseCode = "400", - description = "Corpo inválido ou falha de validação nos dados enviados", - content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + ), @ApiResponse( responseCode = "404", - description = "Categoria inexistente ou não encontrada para o ID informado", + description = "Categoria não encontrada", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) } ) - ResponseEntity update (Long id, Category obj); + ResponseEntity delete(Long id); } diff --git a/api/src/main/java/com/orderflow/ecommerce/controllers/docs/ProductControllerDocs.java b/api/src/main/java/com/orderflow/ecommerce/controllers/docs/ProductControllerDocs.java index 08fe007..86a1487 100644 --- a/api/src/main/java/com/orderflow/ecommerce/controllers/docs/ProductControllerDocs.java +++ b/api/src/main/java/com/orderflow/ecommerce/controllers/docs/ProductControllerDocs.java @@ -1,6 +1,9 @@ package com.orderflow.ecommerce.controllers.docs; import com.orderflow.ecommerce.dtos.ErrorResponse; +import com.orderflow.ecommerce.dtos.ProductFilter; +import com.orderflow.ecommerce.dtos.ProductRequest; +import com.orderflow.ecommerce.dtos.ProductResponse; import com.orderflow.ecommerce.entities.Product; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -10,6 +13,8 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import java.util.List; @@ -19,16 +24,29 @@ public interface ProductControllerDocs { @Operation( summary = "Lista todos os produtos", - description = "Retorna uma lista de todos os produtos existentes no sistema", + description = "Retorna uma lista paginada de todos os produtos. Todos os filtros são opcionais", + parameters = { + @Parameter(name = "name", description = "Filtro parcial por nome do produto", example = "notebook"), + @Parameter(name = "categoryName", description = "Filtro parcial por nome da categoria", example = "Eletrônicos"), + @Parameter(name = "minPrice", description = "Preço mínimo", example = "1000.00"), + @Parameter(name = "maxPrice", description = "Preço máximo", example = "5000.00"), + @Parameter(name = "inStock", description = "Apenas produtos com estoque disponível", example = "true"), + @Parameter(name = "page", description = "Número da página (começa em 0)", example = "0"), + @Parameter(name = "size", description = "Quantidade de itens por página", example = "10"), + @Parameter(name = "sort", description = "Campo e direção de ordenação", example = "name,asc") + }, responses = { @ApiResponse( responseCode = "200", description = "Lista obtida com sucesso", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = Product.class))) + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ProductResponse.class))) ) } ) - ResponseEntity> findAll(); + ResponseEntity> findAll( + @Parameter(hidden = true) ProductFilter filter, + @Parameter(hidden = true) Pageable pageable + ); @Operation( summary = "Obtém produto por id", @@ -44,35 +62,35 @@ public interface ProductControllerDocs { @ApiResponse( responseCode = "200", description = "Produto encontrado com sucesso", - content = @Content(schema = @Schema(implementation = Product.class))), + content = @Content(schema = @Schema(implementation = ProductResponse.class))), @ApiResponse( responseCode = "404", description = "Produto inexistente ou não encontrado para o ID informado", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) } ) - ResponseEntity findById(Long id); + ResponseEntity findById(Long id); @Operation( summary = "Cria um produto", - description = "Insere um novo produto no sistema. O campo 'id' deve ser omitido no envio.", + description = "Insere um novo produto no sistema.", requestBody = @RequestBody( description = "Dados do produto a ser criado", required = true, - content = @Content(schema = @Schema(implementation = Product.class)) + content = @Content(schema = @Schema(implementation = ProductRequest.class)) ), responses = { @ApiResponse( responseCode = "200", description = "Produto criado e persistido com sucesso", - content = @Content(schema = @Schema(implementation = Product.class))), + content = @Content(schema = @Schema(implementation = ProductResponse.class))), @ApiResponse( responseCode = "400", description = "Corpo inválido ou falha de validação nos dados enviados", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) } ) - ResponseEntity insert(Product obj); + ResponseEntity create(ProductRequest request); @Operation( summary = "Remove produto por id", @@ -89,7 +107,11 @@ public interface ProductControllerDocs { responseCode = "204", description = "Exclusão processada (idempotente se o registro não existir)", content = @Content - ) + ), + @ApiResponse( + responseCode = "404", + description = "Produto não encontrado", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) } ) ResponseEntity delete(Long id); @@ -108,13 +130,13 @@ public interface ProductControllerDocs { requestBody = @RequestBody( description = "Novos dados para atualização de produto", required = true, - content = @Content(schema = @Schema(implementation = Product.class)) + content = @Content(schema = @Schema(implementation = ProductRequest.class)) ), responses = { @ApiResponse( responseCode = "200", description = "Produto atualizado com sucesso", - content = @Content(schema = @Schema(implementation = Product.class))), + content = @Content(schema = @Schema(implementation = ProductResponse.class))), @ApiResponse( responseCode = "400", description = "Corpo inválido ou falha de validação nos dados enviados", @@ -125,5 +147,5 @@ public interface ProductControllerDocs { content = @Content(schema = @Schema(implementation = ErrorResponse.class))) } ) - ResponseEntity update (Long id, Product obj); + ResponseEntity update(Long id, ProductRequest request); } diff --git a/api/src/main/java/com/orderflow/ecommerce/dtos/CategoryRequest.java b/api/src/main/java/com/orderflow/ecommerce/dtos/CategoryRequest.java new file mode 100644 index 0000000..a2d397f --- /dev/null +++ b/api/src/main/java/com/orderflow/ecommerce/dtos/CategoryRequest.java @@ -0,0 +1,14 @@ +package com.orderflow.ecommerce.dtos; + +import jakarta.validation.constraints.*; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class CategoryRequest { + + @NotBlank(message = "O nome da categoria é obrigatório") + private String name; +} diff --git a/api/src/main/java/com/orderflow/ecommerce/dtos/CategoryResponse.java b/api/src/main/java/com/orderflow/ecommerce/dtos/CategoryResponse.java new file mode 100644 index 0000000..cc9dafa --- /dev/null +++ b/api/src/main/java/com/orderflow/ecommerce/dtos/CategoryResponse.java @@ -0,0 +1,7 @@ +package com.orderflow.ecommerce.dtos; + +public record CategoryResponse( + Long id, + String name +) { +} diff --git a/api/src/main/java/com/orderflow/ecommerce/dtos/ProductFilter.java b/api/src/main/java/com/orderflow/ecommerce/dtos/ProductFilter.java new file mode 100644 index 0000000..5d70a42 --- /dev/null +++ b/api/src/main/java/com/orderflow/ecommerce/dtos/ProductFilter.java @@ -0,0 +1,11 @@ +package com.orderflow.ecommerce.dtos; + +import java.math.BigDecimal; + +public record ProductFilter( + String name, + String categoryName, + BigDecimal minPrice, + BigDecimal maxPrice, + Boolean inStock +) {} diff --git a/api/src/main/java/com/orderflow/ecommerce/dtos/ProductRequest.java b/api/src/main/java/com/orderflow/ecommerce/dtos/ProductRequest.java new file mode 100644 index 0000000..969cba1 --- /dev/null +++ b/api/src/main/java/com/orderflow/ecommerce/dtos/ProductRequest.java @@ -0,0 +1,27 @@ +package com.orderflow.ecommerce.dtos; + +import jakarta.validation.constraints.*; +import lombok.*; + +import java.math.BigDecimal; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ProductRequest { + + @NotBlank(message = "O nome do produto é obrigatório") + private String name; + + private String description; + + @NotNull(message = "O preço é obrigatório") + @Positive(message = "O preço deve ser positivo") + private BigDecimal price; + + @PositiveOrZero(message = "O estoque não pode ser negativo") + private Integer stockQuantity; + + private Long categoryId; +} diff --git a/api/src/main/java/com/orderflow/ecommerce/dtos/ProductResponse.java b/api/src/main/java/com/orderflow/ecommerce/dtos/ProductResponse.java new file mode 100644 index 0000000..3600725 --- /dev/null +++ b/api/src/main/java/com/orderflow/ecommerce/dtos/ProductResponse.java @@ -0,0 +1,12 @@ +package com.orderflow.ecommerce.dtos; + +import java.math.BigDecimal; + +public record ProductResponse( + Long id, + String name, + String description, + BigDecimal price, + Integer stockQuantity, + CategoryResponse category +) {} diff --git a/api/src/main/java/com/orderflow/ecommerce/entities/Category.java b/api/src/main/java/com/orderflow/ecommerce/entities/Category.java index d193ab2..67968e5 100644 --- a/api/src/main/java/com/orderflow/ecommerce/entities/Category.java +++ b/api/src/main/java/com/orderflow/ecommerce/entities/Category.java @@ -18,7 +18,5 @@ public class Category { private Long id; @Column(nullable = false, unique = true) - @NotBlank(message = "O nome da categoria é obrigatório") - @Pattern(regexp = "^[a-zA-ZÀ-ÿ ]+$", message = "O nome deve conter apenas letras") private String name; } diff --git a/api/src/main/java/com/orderflow/ecommerce/entities/Product.java b/api/src/main/java/com/orderflow/ecommerce/entities/Product.java index 5f2c96e..cbb9ed6 100644 --- a/api/src/main/java/com/orderflow/ecommerce/entities/Product.java +++ b/api/src/main/java/com/orderflow/ecommerce/entities/Product.java @@ -19,15 +19,12 @@ public class Product { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @NotBlank(message = "O nome do produto é obrigatório") - @Pattern(regexp = "^[a-zA-ZÀ-ÿ ]+$", message = "O nome deve conter apenas letras") @Column(nullable = false) private String name; @Column(columnDefinition = "TEXT") private String description; - @NotNull(message = "O preço é obrigatório") @Column(nullable = false) private BigDecimal price; diff --git a/api/src/main/java/com/orderflow/ecommerce/exceptions/GlobalExceptionHandler.java b/api/src/main/java/com/orderflow/ecommerce/exceptions/GlobalExceptionHandler.java index c850fbf..d23ad85 100644 --- a/api/src/main/java/com/orderflow/ecommerce/exceptions/GlobalExceptionHandler.java +++ b/api/src/main/java/com/orderflow/ecommerce/exceptions/GlobalExceptionHandler.java @@ -59,4 +59,15 @@ public ResponseEntity handleAccessDenied(AccessDeniedException ex ); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(err); } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleBadRequest(IllegalArgumentException ex, HttpServletRequest request) { + ErrorResponse err = new ErrorResponse( + Instant.now(), + HttpStatus.BAD_REQUEST.value(), + ex.getMessage(), + request.getRequestURI() + ); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(err); + } } diff --git a/api/src/main/java/com/orderflow/ecommerce/mappers/CategoryMapper.java b/api/src/main/java/com/orderflow/ecommerce/mappers/CategoryMapper.java new file mode 100644 index 0000000..8f46b27 --- /dev/null +++ b/api/src/main/java/com/orderflow/ecommerce/mappers/CategoryMapper.java @@ -0,0 +1,16 @@ +package com.orderflow.ecommerce.mappers; + +import com.orderflow.ecommerce.dtos.CategoryRequest; +import com.orderflow.ecommerce.dtos.CategoryResponse; +import com.orderflow.ecommerce.entities.Category; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface CategoryMapper { + + CategoryResponse toResponse(Category category); + + @Mapping(target = "id", ignore = true) + Category toModel(CategoryRequest request); +} diff --git a/api/src/main/java/com/orderflow/ecommerce/mappers/ProductMapper.java b/api/src/main/java/com/orderflow/ecommerce/mappers/ProductMapper.java new file mode 100644 index 0000000..28d7f81 --- /dev/null +++ b/api/src/main/java/com/orderflow/ecommerce/mappers/ProductMapper.java @@ -0,0 +1,22 @@ +package com.orderflow.ecommerce.mappers; + +import com.orderflow.ecommerce.dtos.ProductRequest; +import com.orderflow.ecommerce.dtos.ProductResponse; +import com.orderflow.ecommerce.entities.Category; +import com.orderflow.ecommerce.entities.Product; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring", uses = CategoryMapper.class) +public interface ProductMapper { + + ProductResponse toResponse(Product product); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "name", source = "request.name") + @Mapping(target = "description", source = "request.description") + @Mapping(target = "price", source = "request.price") + @Mapping(target = "stockQuantity", source = "request.stockQuantity") + @Mapping(target = "category", source = "category") + Product toEntity(ProductRequest request, Category category); +} diff --git a/api/src/main/java/com/orderflow/ecommerce/repositories/CategoryRepository.java b/api/src/main/java/com/orderflow/ecommerce/repositories/CategoryRepository.java index cebc3c0..8bf2e00 100644 --- a/api/src/main/java/com/orderflow/ecommerce/repositories/CategoryRepository.java +++ b/api/src/main/java/com/orderflow/ecommerce/repositories/CategoryRepository.java @@ -1,9 +1,17 @@ package com.orderflow.ecommerce.repositories; import com.orderflow.ecommerce.entities.Category; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @Repository public interface CategoryRepository extends JpaRepository { + + boolean existsByName(String name); + + @Query("SELECT c FROM Category c WHERE LOWER(c.name) LIKE LOWER(CONCAT(:name, '%'))") + Page findByName(String name, Pageable pageable); } \ No newline at end of file diff --git a/api/src/main/java/com/orderflow/ecommerce/repositories/ProductRepository.java b/api/src/main/java/com/orderflow/ecommerce/repositories/ProductRepository.java index d49a9fe..a9d5857 100644 --- a/api/src/main/java/com/orderflow/ecommerce/repositories/ProductRepository.java +++ b/api/src/main/java/com/orderflow/ecommerce/repositories/ProductRepository.java @@ -2,8 +2,11 @@ import com.orderflow.ecommerce.entities.Product; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; @Repository -public interface ProductRepository extends JpaRepository { +public interface ProductRepository extends JpaRepository, JpaSpecificationExecutor { + + boolean existsByName(String name); } diff --git a/api/src/main/java/com/orderflow/ecommerce/services/CategoryService.java b/api/src/main/java/com/orderflow/ecommerce/services/CategoryService.java new file mode 100644 index 0000000..147c0a3 --- /dev/null +++ b/api/src/main/java/com/orderflow/ecommerce/services/CategoryService.java @@ -0,0 +1,81 @@ +package com.orderflow.ecommerce.services; + +import com.orderflow.ecommerce.dtos.CategoryRequest; +import com.orderflow.ecommerce.dtos.CategoryResponse; +import com.orderflow.ecommerce.entities.Category; +import com.orderflow.ecommerce.mappers.CategoryMapper; +import com.orderflow.ecommerce.repositories.CategoryRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.NoSuchElementException; + +@Service +public class CategoryService { + + private final static Logger log = LoggerFactory.getLogger(CategoryService.class); + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private CategoryMapper categoryMapper; + + public CategoryResponse create(CategoryRequest request) { + validateCategory(request, null); + + // TO-DO: aplicar links HATEOAS + Category entity = categoryMapper.toModel(request); + Category saved = categoryRepository.save(entity); + log.info("Category created with ID: {} and name: {}", saved.getId(), saved.getName()); + return categoryMapper.toResponse(saved); + } + + // TO-DO: adicionar paginação com suporte para links HATEAOS + public Page findAll(Pageable pageable) { + return categoryRepository.findAll(pageable) + .map(categoryMapper::toResponse); + } + + // TO-DO: adicionar paginação com suporte para links HATEAOS + public Page findByName(String name, Pageable pageable) { + return categoryRepository.findByName(name, pageable) + .map(categoryMapper::toResponse); + } + + public CategoryResponse findById(Long id) { + Category entity = findEntityById(id); + return categoryMapper.toResponse(entity); + } + + public CategoryResponse update(Long id, CategoryRequest request) { + Category existing = findEntityById(id); + validateCategory(request, existing); + existing.setName(request.getName()); + Category saved = categoryRepository.save(existing); + log.info("Category updated with ID: {} and name: {}", saved.getId(), saved.getName()); + return categoryMapper.toResponse(saved); + } + + public void delete(Long id) { + Category entity = findEntityById(id); + categoryRepository.delete(entity); + log.info("Category deleted with ID: {}",id); + } + + private Category findEntityById(Long id) { + return categoryRepository.findById(id) + .orElseThrow(() -> new NoSuchElementException("Categoria não encontrada com ID: " + id)); + } + + private void validateCategory(CategoryRequest request, Category existing) { + boolean nameChanged = existing == null || !existing.getName().equals(request.getName()); + if (nameChanged && categoryRepository.existsByName(request.getName())) { + throw new IllegalArgumentException("Já existe uma categoria com o nome: " + request.getName()); + } + } +} diff --git a/api/src/main/java/com/orderflow/ecommerce/services/ProductService.java b/api/src/main/java/com/orderflow/ecommerce/services/ProductService.java new file mode 100644 index 0000000..5e72857 --- /dev/null +++ b/api/src/main/java/com/orderflow/ecommerce/services/ProductService.java @@ -0,0 +1,92 @@ +package com.orderflow.ecommerce.services; + +import com.orderflow.ecommerce.dtos.ProductFilter; +import com.orderflow.ecommerce.dtos.ProductRequest; +import com.orderflow.ecommerce.dtos.ProductResponse; +import com.orderflow.ecommerce.entities.Category; +import com.orderflow.ecommerce.entities.Product; +import com.orderflow.ecommerce.mappers.ProductMapper; +import com.orderflow.ecommerce.repositories.CategoryRepository; +import com.orderflow.ecommerce.repositories.ProductRepository; +import com.orderflow.ecommerce.specifications.ProductSpecification; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.NoSuchElementException; + +@Service +public class ProductService { + + private static final Logger log = LoggerFactory.getLogger(ProductService.class); + + @Autowired + private ProductRepository productRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private ProductMapper productMapper; + + public ProductResponse create(ProductRequest request) { + validateProduct(request, null); + + Category category = findCategoryById(request.getCategoryId()); + Product entity = productMapper.toEntity(request, category); + Product saved = productRepository.save(entity); + log.info("Product created with ID: {}", saved.getId()); + return productMapper.toResponse(saved); + } + + public Page findAll(ProductFilter filter, Pageable pageable) { + return productRepository.findAll(ProductSpecification.withFilters(filter), pageable) + .map(productMapper::toResponse); + } + + public ProductResponse findById(Long id) { + Product entity = findEntityById(id); + return productMapper.toResponse(entity); + } + + public ProductResponse update(ProductRequest request, Long id) { + Product existing = findEntityById(id); + Category category = findCategoryById(request.getCategoryId()); + validateProduct(request, existing); + existing.setName(request.getName()); + existing.setDescription(request.getDescription()); + existing.setPrice(request.getPrice()); + existing.setStockQuantity(request.getStockQuantity()); + existing.setCategory(category); + Product saved = productRepository.save(existing); + log.info("Product updated with ID: {}", saved.getId()); + return productMapper.toResponse(saved); + } + + public void delete(Long id) { + Product entity = findEntityById(id); + productRepository.delete(entity); + log.info("Product deleted with ID: {}", id); + } + + private Product findEntityById(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new NoSuchElementException("Produto não encontrado com ID: " + id)); + } + + private void validateProduct(ProductRequest request, Product existing) { + boolean nameChanged = existing == null || !existing.getName().equals(request.getName()); + if (nameChanged && productRepository.existsByName(request.getName())) { + throw new IllegalArgumentException("Já existe um produto com o nome: " + request.getName()); + } + } + + private Category findCategoryById(Long categoryId) { + if (categoryId == null) return null; + return categoryRepository.findById(categoryId) + .orElseThrow(() -> new NoSuchElementException("Categoria não encotrada com ID: " + categoryId)); + } +} diff --git a/api/src/main/java/com/orderflow/ecommerce/specifications/ProductSpecification.java b/api/src/main/java/com/orderflow/ecommerce/specifications/ProductSpecification.java new file mode 100644 index 0000000..21f4000 --- /dev/null +++ b/api/src/main/java/com/orderflow/ecommerce/specifications/ProductSpecification.java @@ -0,0 +1,51 @@ +package com.orderflow.ecommerce.specifications; + +import com.orderflow.ecommerce.dtos.ProductFilter; +import com.orderflow.ecommerce.entities.Product; +import org.springframework.data.jpa.domain.Specification; + +import java.math.BigDecimal; + +public class ProductSpecification { + + public static Specification withFilters(ProductFilter filter) { + return Specification + .where(hasName(filter.name())) + .and(hasCategory(filter.categoryName())) + .and(hasPriceBetween(filter.minPrice(), filter.maxPrice())); + } + + private static Specification hasName(String name) { + return (root, query, cb) -> name == null || name.isBlank() + ? null : cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%"); + } + + private static Specification hasCategory(String categoryName) { + return (root, query, cb) -> categoryName == null || categoryName.isBlank() + ? null + : cb.like( + cb.lower(root.get("category").get("name")), + "%" + categoryName.toLowerCase() + "%" + ); + } + + private static Specification hasPriceBetween(BigDecimal minPrice, BigDecimal maxPrice) { + return (root, query, cb) -> { + if(minPrice != null && maxPrice!= null) { + return cb.between(root.get("price"), minPrice, maxPrice); + } + if (minPrice != null) { + return cb.greaterThanOrEqualTo(root.get("price"), minPrice); + } + if (maxPrice != null) { + return cb.lessThanOrEqualTo(root.get("price"), maxPrice); + } + return null; + }; + } + + private static Specification isInStock (Boolean inStock) { + return (root, query, cb) -> inStock == null || !inStock + ? null : cb.greaterThan(root.get("stockQuantity"), 0); + } +} diff --git a/api/src/test/java/com/orderflow/ecommerce/controllers/CategoryControllerTest.java b/api/src/test/java/com/orderflow/ecommerce/controllers/CategoryControllerTest.java new file mode 100644 index 0000000..a867541 --- /dev/null +++ b/api/src/test/java/com/orderflow/ecommerce/controllers/CategoryControllerTest.java @@ -0,0 +1,180 @@ +package com.orderflow.ecommerce.controllers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.orderflow.ecommerce.dtos.CategoryRequest; +import com.orderflow.ecommerce.dtos.CategoryResponse; +import com.orderflow.ecommerce.mappers.CategoryMapper; +import com.orderflow.ecommerce.services.CategoryService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.NoSuchElementException; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(CategoryController.class) +@AutoConfigureMockMvc(addFilters = false) +public class CategoryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private CategoryService categoryService; + + private CategoryResponse response; + private CategoryRequest request; + + @BeforeEach + void setUp() { + response = new CategoryResponse(1L, "Eletrônicos"); + request = new CategoryRequest("Eletrônicos"); + } + + @Nested + class createCategory { + @Test + void shouldCreateCategoryWithStatus201() throws Exception { + when(categoryService.create(any(CategoryRequest.class))).thenReturn(response); + + mockMvc.perform(post("/categories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Eletrônicos")); + } + + @Test + void shouldReturnStatus400WhenCreateWithBlankName() throws Exception { + CategoryRequest invalidRequest = new CategoryRequest(""); + + mockMvc.perform(post("/categories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturnStatus400WhenCreateWithDuplicateName() throws Exception { + when(categoryService.create(any(CategoryRequest.class))) + .thenThrow(new IllegalArgumentException("Já existe uma categoria com o nome: Eletrônicos")); + + mockMvc.perform(post("/categories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + class findAllCategory { + @Test + void shouldReturnPagedCategoriesWithStatus200() throws Exception { + Page page = new PageImpl<>(List.of(response)); + when(categoryService.findAll(any(Pageable.class))).thenReturn(page); + + mockMvc.perform(get("/categories") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].name").value("Eletrônicos")); + } + + @Test + void shouldReturnFilteredCategoriesByNameWithStatus200() throws Exception { + Page page = new PageImpl<>(List.of(response)); + when(categoryService.findByName(eq("Eletrônicos"), any(Pageable.class))).thenReturn(page); + + mockMvc.perform(get("/categories") + .param("name", "Eletrônicos") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].name").value("Eletrônicos")); + } + } + + @Nested + class findByIdCategory { + @Test + void shouldReturnCategoryByIdWithStatus200() throws Exception { + when(categoryService.findById(1L)).thenReturn(response); + + mockMvc.perform(get("/categories/1") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.name").value("Eletrônicos")); + } + + @Test + void shouldReturnStatus404WhenCategoryNotFound() throws Exception { + when(categoryService.findById(99L)).thenThrow(new NoSuchElementException("Categoria não encontrada com ID: 99")); + + mockMvc.perform(get("/categories/99") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + } + + @Nested + class updateCategory { + @Test + void shouldUpdateCategoryWithStatus200() throws Exception { + CategoryResponse updated = new CategoryResponse(1L, "Games"); + when(categoryService.update(eq(1L), any(CategoryRequest.class))).thenReturn(updated); + + mockMvc.perform(put("/categories/1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new CategoryRequest("Games")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Games")); + } + + @Test + void shouldReturnStatus404WhenUpdateNonExistentCategory() throws Exception { + when(categoryService.update(eq(99L), any(CategoryRequest.class))) + .thenThrow(new NoSuchElementException("Categoria não encontrada com ID: 99")); + + mockMvc.perform(put("/categories/99") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()); + } + } + + @Nested + class deleteCategory { + @Test + void shouldDeleteCategoryWithStatus204() throws Exception { + doNothing().when(categoryService).delete(1L); + + mockMvc.perform(delete("/categories/1")) + .andExpect(status().isNoContent()); + } + + @Test + void shouldReturnStatus404WhenDeleteNonExistentCategory() throws Exception { + doThrow(new NoSuchElementException("Categoria não encontrada com ID: 99")) + .when(categoryService).delete(99L); + + mockMvc.perform(delete("/categories/99")) + .andExpect(status().isNotFound()); + } + } +} diff --git a/api/src/test/java/com/orderflow/ecommerce/controllers/ProductControllerTest.java b/api/src/test/java/com/orderflow/ecommerce/controllers/ProductControllerTest.java new file mode 100644 index 0000000..950450a --- /dev/null +++ b/api/src/test/java/com/orderflow/ecommerce/controllers/ProductControllerTest.java @@ -0,0 +1,225 @@ +package com.orderflow.ecommerce.controllers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.orderflow.ecommerce.dtos.CategoryResponse; +import com.orderflow.ecommerce.dtos.ProductFilter; +import com.orderflow.ecommerce.dtos.ProductRequest; +import com.orderflow.ecommerce.dtos.ProductResponse; +import com.orderflow.ecommerce.services.ProductService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.util.List; +import java.util.NoSuchElementException; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(ProductController.class) +@AutoConfigureMockMvc(addFilters = false) +public class ProductControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private ProductService productService; + + private ProductResponse response; + private ProductRequest request; + private CategoryResponse categoryResponse; + + @BeforeEach + void setUp() { + categoryResponse = new CategoryResponse(1L, "Eletrônicos"); + response = new ProductResponse(1L, "Notebook Dell", "Notebook i7", new BigDecimal("4999.99"), 10, categoryResponse); + request = new ProductRequest("Notebook Dell", "Notebook i7", new BigDecimal("4999.99"), 10, 1L); + } + + @Nested + class createProduct { + + @Test + void shouldCreateProductWithStatus201() throws Exception { + when(productService.create(any(ProductRequest.class))).thenReturn(response); + + mockMvc.perform(post("/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Notebook Dell")); + } + + @Test + void shouldReturnStatus400WhenCreateWithBlankName() throws Exception { + ProductRequest invalidRequest = new ProductRequest("", "desc", new BigDecimal("999"), 1, 1L); + + mockMvc.perform(post("/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturnStatus400WhenCreateWithDuplicateName() throws Exception { + when(productService.create(any(ProductRequest.class))) + .thenThrow(new IllegalArgumentException("Já existe um produto com o nome: Notebook Dell")); + + mockMvc.perform(post("/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturnStatus400WhenCreateWithNullPrice() throws Exception { + ProductRequest invalidRequest = new ProductRequest("Notebook Dell", "desc", null, 1, 1L); + + mockMvc.perform(post("/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + class findAllProduct { + + @Test + void shouldReturnPagedProductsWithStatus200() throws Exception { + Page page = new PageImpl<>(List.of(response)); + when(productService.findAll(any(ProductFilter.class), any(Pageable.class))).thenReturn(page); + + mockMvc.perform(get("/products") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].name").value("Notebook Dell")); + } + + @Test + void shouldReturnFilteredProductsByNameWithStatus200() throws Exception { + Page page = new PageImpl<>(List.of(response)); + when(productService.findAll(any(ProductFilter.class), any(Pageable.class))).thenReturn(page); + + mockMvc.perform(get("/products") + .param("name", "Notebook") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].name").value("Notebook Dell")); + } + + @Test + void shouldReturnFilteredProductsByPriceRangeWithStatus200() throws Exception { + Page page = new PageImpl<>(List.of(response)); + when(productService.findAll(any(ProductFilter.class), any(Pageable.class))).thenReturn(page); + + mockMvc.perform(get("/products") + .param("minPrice", "1000") + .param("maxPrice", "6000") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].price").value(4999.99)); + } + + @Test + void shouldReturnFilteredProductsInStockWithStatus200() throws Exception { + Page page = new PageImpl<>(List.of(response)); + when(productService.findAll(any(ProductFilter.class), any(Pageable.class))).thenReturn(page); + + mockMvc.perform(get("/products") + .param("inStock", "true") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].stockQuantity").value(10)); + } + } + + @Nested + class findByIdProduct { + + @Test + void shouldReturnProductByIdWithStatus200() throws Exception { + when(productService.findById(1L)).thenReturn(response); + + mockMvc.perform(get("/products/1") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.name").value("Notebook Dell")); + } + + @Test + void shouldReturnStatus404WhenProductNotFound() throws Exception { + when(productService.findById(99L)) + .thenThrow(new NoSuchElementException("Produto não encontrado com ID: 99")); + + mockMvc.perform(get("/products/99") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + } + + @Nested + class updateProduct { + + @Test + void shouldUpdateProductWithStatus200() throws Exception { + ProductResponse updated = new ProductResponse(1L, "Notebook Dell Pro", "Notebook i9", new BigDecimal("7999.99"), 5, categoryResponse); + when(productService.update(any(ProductRequest.class), eq(1L))).thenReturn(updated); + + mockMvc.perform(put("/products/1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Notebook Dell Pro")); + } + + @Test + void shouldReturnStatus404WhenUpdateNonExistentProduct() throws Exception { + when(productService.update(any(ProductRequest.class), eq(99L))) + .thenThrow(new NoSuchElementException("Produto não encontrado com ID: 99")); + + mockMvc.perform(put("/products/99") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()); + } + } + + @Nested + class deleteProduct { + + @Test + void shouldDeleteProductWithStatus204() throws Exception { + doNothing().when(productService).delete(1L); + + mockMvc.perform(delete("/products/1")) + .andExpect(status().isNoContent()); + } + + @Test + void shouldReturnStatus404WhenDeleteNonExistentProduct() throws Exception { + doThrow(new NoSuchElementException("Produto não encontrado com ID: 99")) + .when(productService).delete(99L); + + mockMvc.perform(delete("/products/99")) + .andExpect(status().isNotFound()); + } + } +} diff --git a/api/src/test/java/com/orderflow/ecommerce/services/CategoryServiceTest.java b/api/src/test/java/com/orderflow/ecommerce/services/CategoryServiceTest.java new file mode 100644 index 0000000..52334fa --- /dev/null +++ b/api/src/test/java/com/orderflow/ecommerce/services/CategoryServiceTest.java @@ -0,0 +1,199 @@ +package com.orderflow.ecommerce.services; + +import com.orderflow.ecommerce.dtos.CategoryRequest; +import com.orderflow.ecommerce.dtos.CategoryResponse; +import com.orderflow.ecommerce.entities.Category; +import com.orderflow.ecommerce.mappers.CategoryMapper; +import com.orderflow.ecommerce.repositories.CategoryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +public class CategoryServiceTest { + + @Mock + private CategoryRepository categoryRepository; + + @Mock + private CategoryMapper categoryMapper; + + @InjectMocks + private CategoryService categoryService; + + private Category category; + private CategoryRequest categoryRequest; + private CategoryResponse categoryResponse; + + @BeforeEach + void setUp() { + category = new Category(1L, "Eletrônicos"); + categoryRequest = new CategoryRequest("Eletrônicos"); + categoryResponse = new CategoryResponse(1L, "Eletrônicos"); + } + + @Nested + class CreateCategory { + + @Test + void shouldCreateCategorySuccessfully() { + when(categoryRepository.existsByName(categoryRequest.getName())).thenReturn(false); + when(categoryMapper.toModel(categoryRequest)).thenReturn(category); + when(categoryRepository.save(category)).thenReturn(category); + when(categoryMapper.toResponse(category)).thenReturn(categoryResponse); + + CategoryResponse result = categoryService.create(categoryRequest); + + assertNotNull(result); + assertEquals("Eletrônicos", result.name()); + verify(categoryRepository).save(category); + } + + @Test + void shouldThrowWhenCreatingCategoryWithDuplicateName() { + when(categoryRepository.existsByName(categoryRequest.getName())).thenReturn(true); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> categoryService.create(categoryRequest)); + + assertEquals("Já existe uma categoria com o nome: Eletrônicos", ex.getMessage()); + verify(categoryRepository, never()).save(any()); + } + + } + + @Nested + class FindByIdCategory { + + @Test + void shouldReturnCategoryWhenFoundById () { + when(categoryRepository.findById(1L)).thenReturn(Optional.of(category)); + when(categoryMapper.toResponse(category)).thenReturn(categoryResponse); + + CategoryResponse result = categoryService.findById(1L); + + assertNotNull(result); + assertEquals(1L, result.id()); + } + + @Test + void shouldThrowWhenCategoryNotFoundById() { + when(categoryRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows(NoSuchElementException.class, () -> categoryService.findById(99L)); + } + } + + @Nested + class FindAllCategory { + + @Test + void shouldReturnPagedCategories() { + Pageable pageable = PageRequest.of(0,10); + Page page = new PageImpl<>(List.of(category)); + + when(categoryRepository.findAll(pageable)).thenReturn(page); + when(categoryMapper.toResponse(category)).thenReturn(categoryResponse); + + Page result = categoryService.findAll(pageable); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + } + + @Test + void shouldReturnFilteredCategoriesWhenNameParamProvided() { + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(category)); + + when(categoryRepository.findByName("Eletrônicos", pageable)).thenReturn(page); + when(categoryMapper.toResponse(category)).thenReturn(categoryResponse); + + Page result = categoryService.findByName("Eletrônicos", pageable); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + assertEquals("Eletrônicos", result.getContent().getFirst().name()); + verify(categoryRepository).findByName("Eletrônicos", pageable); + verify(categoryRepository, never()).findAll(pageable); + } + } + + @Nested + class UpdateCategory { + + @Test + void shouldUpdateCategorySuccessfully() { + CategoryRequest updateRequest = new CategoryRequest("Games"); + Category updated = new Category(1L, "Games"); + CategoryResponse updatedResponse = new CategoryResponse(1L, "Games"); + + when(categoryRepository.findById(1L)).thenReturn(Optional.of(category)); + when(categoryRepository.existsByName("Games")).thenReturn(false); + when(categoryRepository.save(category)).thenReturn(updated); + when(categoryMapper.toResponse(updated)).thenReturn(updatedResponse); + + CategoryResponse result = categoryService.update(1L, updateRequest); + + assertEquals("Games", result.name()); + verify(categoryRepository).save(category); + } + + @Test + void shouldThrowWhenUpdatingWithDuplicateName() { + CategoryRequest updateRequest = new CategoryRequest("Games"); + + when(categoryRepository.findById(1L)).thenReturn(Optional.of(category)); + when(categoryRepository.existsByName("Games")).thenReturn(true); + + assertThrows(IllegalArgumentException.class, () -> categoryService.update(1L, updateRequest)); + verify(categoryRepository, never()).save(any()); + } + + @Test + void shouldThrowWhenUpdatingNonExistentCategory() { + when(categoryRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows(NoSuchElementException.class, () -> categoryService.update(99L, categoryRequest)); + } + } + + @Nested + class DeleteCategory { + + @Test + void shouldDeleteCategorySuccessfully() { + when(categoryRepository.findById(1L)).thenReturn(Optional.of(category)); + + categoryService.delete(1L); + + verify(categoryRepository).delete(category); + } + + @Test + void shouldThrowWhenDeletingNonExistentCategory() { + when(categoryRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows(NoSuchElementException.class, + () -> categoryService.delete(99L)); + + verify(categoryRepository, never()).delete(any()); + } + } +} diff --git a/api/src/test/java/com/orderflow/ecommerce/services/ProductServiceTest.java b/api/src/test/java/com/orderflow/ecommerce/services/ProductServiceTest.java new file mode 100644 index 0000000..b641556 --- /dev/null +++ b/api/src/test/java/com/orderflow/ecommerce/services/ProductServiceTest.java @@ -0,0 +1,250 @@ +package com.orderflow.ecommerce.services; + +import com.orderflow.ecommerce.dtos.CategoryResponse; +import com.orderflow.ecommerce.dtos.ProductFilter; +import com.orderflow.ecommerce.dtos.ProductRequest; +import com.orderflow.ecommerce.dtos.ProductResponse; +import com.orderflow.ecommerce.entities.Category; +import com.orderflow.ecommerce.entities.Product; +import com.orderflow.ecommerce.mappers.ProductMapper; +import com.orderflow.ecommerce.repositories.CategoryRepository; +import com.orderflow.ecommerce.repositories.ProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; + +import java.math.BigDecimal; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +public class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + @Mock + private CategoryRepository categoryRepository; + + @Mock + private ProductMapper productMapper; + + @InjectMocks + private ProductService productService; + + private Product product; + private Category category; + private ProductRequest productRequest; + private ProductResponse productResponse; + + @BeforeEach + void setUp() { + category = new Category(1L, "Eletrônicos"); + product = new Product(1L, "Notebook Dell", "Notebook i7", new BigDecimal("4999.99"), 10, category); + productRequest = new ProductRequest("Notebook Dell", "Notebook i7", new BigDecimal("4999.99"), 10, 1L); + productResponse = new ProductResponse(1L, "Notebook Dell", "Notebook i7", new BigDecimal("4999.99"), 10, new CategoryResponse(1L, "Eletrônicos")); + } + + @Nested + class CreateProduct { + + @Test + void shouldCreateProductSuccessfully() { + when(productRepository.existsByName(productRequest.getName())).thenReturn(false); + when(categoryRepository.findById(1L)).thenReturn(Optional.of(category)); + when(productMapper.toEntity(productRequest, category)).thenReturn(product); + when(productRepository.save(product)).thenReturn(product); + when(productMapper.toResponse(product)).thenReturn(productResponse); + + ProductResponse result = productService.create(productRequest); + + assertNotNull(result); + assertEquals("Notebook Dell", result.name()); + verify(productRepository).save(product); + } + + @Test + void shouldThrowWhenCreatingProductWithDuplicateName() { + when(productRepository.existsByName(productRequest.getName())).thenReturn(true); + + assertThrows(IllegalArgumentException.class, () -> productService.create(productRequest)); + + verify(productRepository, never()).save(any()); + } + + @Test + void shouldThrowWhenCreatingProductWithInvalidCategory() { + when(productRepository.existsByName(productRequest.getName())).thenReturn(false); + when(categoryRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThrows(NoSuchElementException.class, () -> productService.create(productRequest)); + + verify(productRepository, never()).save(any()); + } + } + + @Nested + class FindByIdProduct { + + @Test + void shouldReturnProductWhenFoundById() { + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productMapper.toResponse(product)).thenReturn(productResponse); + + ProductResponse result = productService.findById(1L); + + assertNotNull(result); + assertEquals(1L, result.id()); + } + + @Test + void shouldThrowWhenProductNotFoundById() { + when(productRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows(NoSuchElementException.class, () -> productService.findById(99L)); + } + } + + @Nested + class findAllProduct { + + @Test + void shouldReturnPagedProducts() { + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(product)); + ProductFilter filter = new ProductFilter(null, null, null, null, null); + + when(productRepository.findAll(any(Specification.class), eq(pageable))).thenReturn(page); + when(productMapper.toResponse(product)).thenReturn(productResponse); + + Page result = productService.findAll(filter, pageable); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + } + + @Test + void shouldReturnFilteredProductsByName() { + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(product)); + ProductFilter filter = new ProductFilter("Notebook", null, null, null, null); + + when(productRepository.findAll(any(Specification.class), eq(pageable))).thenReturn(page); + when(productMapper.toResponse(product)).thenReturn(productResponse); + + Page result = productService.findAll(filter, pageable); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + assertEquals("Notebook Dell", result.getContent().getFirst().name()); + } + + @Test + void shouldReturnFilteredProductsByPriceRange() { + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(product)); + ProductFilter filter = new ProductFilter(null, null, new BigDecimal("1000"), new BigDecimal("6000"), null); + + when(productRepository.findAll(any(Specification.class), eq(pageable))).thenReturn(page); + when(productMapper.toResponse(product)).thenReturn(productResponse); + + Page result = productService.findAll(filter, pageable); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + } + + @Test + void shouldReturnOnlyInStockProducts() { + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(product)); + ProductFilter filter = new ProductFilter(null, null, null, null, true); + + when(productRepository.findAll(any(Specification.class), eq(pageable))).thenReturn(page); + when(productMapper.toResponse(product)).thenReturn(productResponse); + + Page result = productService.findAll(filter, pageable); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + } + } + + @Nested + class UpdateProduct { + + @Test + void shouldUpdateProductSuccessfully() { + ProductRequest updateRequest = new ProductRequest("Notebook Dell Pro", "Notebook i9", new BigDecimal("7999.99"), 5, 1L); + Product updated = new Product(1L, "Notebook Dell Pro", "Notebook i9", new BigDecimal("7999.99"), 5, category); + ProductResponse updatedResponse = new ProductResponse(1L, "Notebook Dell Pro", "Notebook i9", new BigDecimal("7999.99"), 5, new CategoryResponse(1L, "Eletrônicos")); + + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(categoryRepository.findById(1L)).thenReturn(Optional.of(category)); + when(productRepository.existsByName("Notebook Dell Pro")).thenReturn(false); + when(productRepository.save(product)).thenReturn(updated); + when(productMapper.toResponse(updated)).thenReturn(updatedResponse); + + ProductResponse result = productService.update(updateRequest, 1L); + + assertEquals("Notebook Dell Pro", result.name()); + verify(productRepository).save(product); + } + + @Test + void shouldThrowWhenUpdatingWithDuplicateName() { + ProductRequest updateRequest = new ProductRequest("Outro Notebook", "desc", new BigDecimal("999"), 1, 1L); + + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(categoryRepository.findById(1L)).thenReturn(Optional.of(category)); + when(productRepository.existsByName("Outro Notebook")).thenReturn(true); + + assertThrows(IllegalArgumentException.class, () -> productService.update(updateRequest, 1L)); + + verify(productRepository, never()).save(any()); + } + + @Test + void shouldThrowWhenUpdatingNonExistentProduct() { + when(productRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows(NoSuchElementException.class, () -> productService.update(productRequest, 99L)); + } + } + + @Nested + class DeleteProduct { + + @Test + void shouldDeleteProductSuccessfully() { + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + + productService.delete(1L); + + verify(productRepository).delete(product); + } + + @Test + void shouldThrowWhenDeletingNonExistentProduct() { + when(productRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows(NoSuchElementException.class, () -> productService.delete(99L)); + + verify(productRepository, never()).delete((Product) any()); + } + } +}