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());
+ }
+ }
+}