diff --git a/README.md b/README.md index 2d336a1..7085ec2 100644 --- a/README.md +++ b/README.md @@ -401,11 +401,11 @@ Full CRUD for users, roles, and permissions, still inside `core/auth`. Assignmen ### Tasks -- [ ] `core/catalog`: `Category`, `Product` entity classes (`@Table("categories")`/`@Table("products")`) and `CategoryDto`/`ProductDto`, `CategoryRowMapper`/`ProductRowMapper` (entity) plus `CategoryMapper`/`ProductMapper` (entity ↔ dto), `CategoryDao`/`ProductDao` + impl, `CategoryService`/`ProductService` + impl, `DaoConfig`/`ServiceConfig` -- [ ] Business rule: deleting a category that still has products is rejected (`BusinessRuleException` → 422) -- [ ] `ws`: `CategoryController`, `ProductController`, `CategoryConverter`, `ProductConverter` -- [ ] `WebMvcConfig` updated to import `core/catalog`'s config -- [ ] Tests at all five layers - `CategoryDao`/`ProductDao` each with their own fixture dataset, the category-deletion rejection case, a permission-denied case +- [x] `core/catalog`: `Category`, `Product` entity classes (`@Table("categories")`/`@Table("products")`) and `CategoryDto`/`ProductDto`, `CategoryRowMapper`/`ProductRowMapper` (entity) plus `CategoryMapper`/`ProductMapper` (entity ↔ dto), `CategoryDao`/`ProductDao` + impl, `CategoryService`/`ProductService` + impl, `DaoConfig`/`ServiceConfig` +- [x] Business rule: deleting a category that still has products is rejected (`BusinessRuleException` → 422) +- [x] `ws`: `CategoryController`, `ProductController`, `CategoryConverter`, `ProductConverter` +- [x] `WebMvcConfig` updated to import `core/catalog`'s config +- [x] Tests at all five layers - `CategoryDao`/`ProductDao` each with their own fixture dataset, the category-deletion rejection case, a permission-denied case ## feature/customer diff --git a/core/catalog/pom.xml b/core/catalog/pom.xml index 5c4dca8..7363b0f 100644 --- a/core/catalog/pom.xml +++ b/core/catalog/pom.xml @@ -19,5 +19,22 @@ com.edgareldy.springjdbctutorial common + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/DaoConfig.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/DaoConfig.java new file mode 100644 index 0000000..9c70acf --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/DaoConfig.java @@ -0,0 +1,31 @@ +package com.edgareldy.springjdbctutorial.core.catalog.config; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.impl.CategoryDaoImpl; +import com.edgareldy.springjdbctutorial.core.catalog.dao.impl.ProductDaoImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Declares the DAO beans of the catalog module, typed by interface (no stereotype on the implementations). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +public class DaoConfig { + + @Bean + public CategoryDao categoryDao(JdbcTemplate jdbcTemplate) { + return new CategoryDaoImpl(jdbcTemplate); + } + + @Bean + public ProductDao productDao(JdbcTemplate jdbcTemplate) { + return new ProductDaoImpl(jdbcTemplate); + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/ServiceConfig.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/ServiceConfig.java new file mode 100644 index 0000000..c323837 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/ServiceConfig.java @@ -0,0 +1,34 @@ +package com.edgareldy.springjdbctutorial.core.catalog.config; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService; +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +import com.edgareldy.springjdbctutorial.core.catalog.service.impl.CategoryServiceImpl; +import com.edgareldy.springjdbctutorial.core.catalog.service.impl.ProductServiceImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * Declares the service beans of the catalog module. Imports its DaoConfig so importing this class alone is enough. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +@Import(DaoConfig.class) +public class ServiceConfig { + + @Bean + public CategoryService categoryService(CategoryDao categoryDao, ProductDao productDao) { + return new CategoryServiceImpl(categoryDao, productDao); + } + + @Bean + public ProductService productService(ProductDao productDao, CategoryDao categoryDao) { + return new ProductServiceImpl(productDao, categoryDao); + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/CategoryDao.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/CategoryDao.java new file mode 100644 index 0000000..76b4282 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/CategoryDao.java @@ -0,0 +1,33 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dao; + +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; + +import java.util.List; +import java.util.Optional; + +/** + * Data access for categories. Entity types only. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface CategoryDao { + + /** One page of categories ordered by id. */ + List findPage(int page, int size); + + long countAll(); + + Optional findById(Long id); + + boolean existsByCategoryName(String categoryName); + + /** Inserts the category and returns it with its generated id. */ + Category insert(Category category); + + void update(Category category); + + void delete(Long id); +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/ProductDao.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/ProductDao.java new file mode 100644 index 0000000..ff73c2b --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/ProductDao.java @@ -0,0 +1,34 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dao; + +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; + +import java.util.List; +import java.util.Optional; + +/** + * Data access for products. Entity types only. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface ProductDao { + + /** One page of products ordered by id, restricted to a category when categoryId is not null. */ + List findPage(int page, int size, Long categoryId); + + /** Number of products, restricted to a category when categoryId is not null. */ + long count(Long categoryId); + + Optional findById(Long id); + + /** Inserts the product and returns it with its generated id. */ + Product insert(Product product); + + void update(Product product); + + void delete(Long id); + + long countByCategoryId(Long categoryId); +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImpl.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImpl.java new file mode 100644 index 0000000..749800d --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImpl.java @@ -0,0 +1,68 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dao.impl; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +import com.edgareldy.springjdbctutorial.core.catalog.mapper.CategoryRowMapper; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Optional; + +/** + * JdbcTemplate implementation of CategoryDao. Declared by @Bean in DaoConfig, no stereotype. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryDaoImpl extends AbstractDao implements CategoryDao { + + private final CategoryRowMapper rowMapper = new CategoryRowMapper(); + + public CategoryDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public List findPage(int page, int size) { + return getJdbcTemplate().query( + "SELECT id, category_name FROM categories ORDER BY id LIMIT ? OFFSET ?", + rowMapper, size, (long) page * size); + } + + @Override + public long countAll() { + return count("SELECT COUNT(*) FROM categories"); + } + + @Override + public Optional findById(Long id) { + return findOne("SELECT id, category_name FROM categories WHERE id = ?", rowMapper, id); + } + + @Override + public boolean existsByCategoryName(String categoryName) { + return count("SELECT COUNT(*) FROM categories WHERE category_name = ?", categoryName) > 0; + } + + @Override + public Category insert(Category category) { + long id = insertReturningId("INSERT INTO categories (category_name) VALUES (?) RETURNING id", + category.getCategoryName()); + category.setId(id); + return category; + } + + @Override + public void update(Category category) { + getJdbcTemplate().update("UPDATE categories SET category_name = ? WHERE id = ?", + category.getCategoryName(), category.getId()); + } + + @Override + public void delete(Long id) { + getJdbcTemplate().update("DELETE FROM categories WHERE id = ?", id); + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImpl.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImpl.java new file mode 100644 index 0000000..f2f3e44 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImpl.java @@ -0,0 +1,78 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dao.impl; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; +import com.edgareldy.springjdbctutorial.core.catalog.mapper.ProductRowMapper; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Optional; + +/** + * JdbcTemplate implementation of ProductDao. Declared by @Bean in DaoConfig, no stereotype. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductDaoImpl extends AbstractDao implements ProductDao { + + private static final String COLUMNS = "SELECT id, category_id, product_name, unit_price FROM products"; + + private final ProductRowMapper rowMapper = new ProductRowMapper(); + + public ProductDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public List findPage(int page, int size, Long categoryId) { + long offset = (long) page * size; + if (categoryId == null) { + return getJdbcTemplate().query(COLUMNS + " ORDER BY id LIMIT ? OFFSET ?", rowMapper, size, offset); + } + return getJdbcTemplate().query(COLUMNS + " WHERE category_id = ? ORDER BY id LIMIT ? OFFSET ?", + rowMapper, categoryId, size, offset); + } + + @Override + public long count(Long categoryId) { + if (categoryId == null) { + return count("SELECT COUNT(*) FROM products"); + } + return countByCategoryId(categoryId); + } + + @Override + public Optional findById(Long id) { + return findOne(COLUMNS + " WHERE id = ?", rowMapper, id); + } + + @Override + public Product insert(Product product) { + long id = insertReturningId( + "INSERT INTO products (category_id, product_name, unit_price) VALUES (?, ?, ?) RETURNING id", + product.getCategoryId(), product.getProductName(), product.getUnitPrice()); + product.setId(id); + return product; + } + + @Override + public void update(Product product) { + getJdbcTemplate().update( + "UPDATE products SET category_id = ?, product_name = ?, unit_price = ? WHERE id = ?", + product.getCategoryId(), product.getProductName(), product.getUnitPrice(), product.getId()); + } + + @Override + public void delete(Long id) { + getJdbcTemplate().update("DELETE FROM products WHERE id = ?", id); + } + + @Override + public long countByCategoryId(Long categoryId) { + return count("SELECT COUNT(*) FROM products WHERE category_id = ?", categoryId); + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/CategoryDto.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/CategoryDto.java new file mode 100644 index 0000000..9c1cfc1 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/CategoryDto.java @@ -0,0 +1,67 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dto; + +import java.util.Objects; + +/** + * Business shape of a category. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryDto { + + private Long id; + + private String categoryName; + + public CategoryDto() { + } + + public CategoryDto(Long id, String categoryName) { + this.id = id; + this.categoryName = categoryName; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CategoryDto that = (CategoryDto) o; + return Objects.equals(id, that.id) && Objects.equals(categoryName, that.categoryName); + } + + @Override + public int hashCode() { + return Objects.hash(id, categoryName); + } + + @Override + public String toString() { + return "CategoryDto{" + + "id=" + id + + ", categoryName=" + categoryName + + "}"; + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/ProductDto.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/ProductDto.java new file mode 100644 index 0000000..e130fcc --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/ProductDto.java @@ -0,0 +1,92 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dto; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Business shape of a product. The unit price is a decimal with two digits. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductDto { + + private Long id; + + private Long categoryId; + + private String productName; + + private BigDecimal unitPrice; + + public ProductDto() { + } + + public ProductDto(Long id, Long categoryId, String productName, BigDecimal unitPrice) { + this.id = id; + this.categoryId = categoryId; + this.productName = productName; + this.unitPrice = unitPrice; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCategoryId() { + return categoryId; + } + + public void setCategoryId(Long categoryId) { + this.categoryId = categoryId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public void setUnitPrice(BigDecimal unitPrice) { + this.unitPrice = unitPrice; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ProductDto that = (ProductDto) o; + return Objects.equals(id, that.id) && Objects.equals(categoryId, that.categoryId) && Objects.equals(productName, that.productName) && Objects.equals(unitPrice, that.unitPrice); + } + + @Override + public int hashCode() { + return Objects.hash(id, categoryId, productName, unitPrice); + } + + @Override + public String toString() { + return "ProductDto{" + + "id=" + id + + ", categoryId=" + categoryId + + ", productName=" + productName + + ", unitPrice=" + unitPrice + + "}"; + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Category.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Category.java new file mode 100644 index 0000000..8a49b04 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Category.java @@ -0,0 +1,74 @@ +package com.edgareldy.springjdbctutorial.core.catalog.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.Objects; + +/** + * Row of the categories table. The jakarta.persistence annotations are documentation only, nothing reads them at runtime. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "categories") +public class Category { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "category_name") + private String categoryName; + + public Category() { + } + + public Category(Long id, String categoryName) { + this.id = id; + this.categoryName = categoryName; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Category that = (Category) o; + return Objects.equals(id, that.id) && Objects.equals(categoryName, that.categoryName); + } + + @Override + public int hashCode() { + return Objects.hash(id, categoryName); + } + + @Override + public String toString() { + return "Category{" + + "id=" + id + + ", categoryName=" + categoryName + + "}"; + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Product.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Product.java new file mode 100644 index 0000000..725080a --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Product.java @@ -0,0 +1,101 @@ +package com.edgareldy.springjdbctutorial.core.catalog.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Row of the products table. The jakarta.persistence annotations are documentation only, nothing reads them at runtime. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "products") +public class Product { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "category_id") + private Long categoryId; + + @Column(name = "product_name") + private String productName; + + @Column(name = "unit_price") + private BigDecimal unitPrice; + + public Product() { + } + + public Product(Long id, Long categoryId, String productName, BigDecimal unitPrice) { + this.id = id; + this.categoryId = categoryId; + this.productName = productName; + this.unitPrice = unitPrice; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCategoryId() { + return categoryId; + } + + public void setCategoryId(Long categoryId) { + this.categoryId = categoryId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public void setUnitPrice(BigDecimal unitPrice) { + this.unitPrice = unitPrice; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Product that = (Product) o; + return Objects.equals(id, that.id) && Objects.equals(categoryId, that.categoryId) && Objects.equals(productName, that.productName) && Objects.equals(unitPrice, that.unitPrice); + } + + @Override + public int hashCode() { + return Objects.hash(id, categoryId, productName, unitPrice); + } + + @Override + public String toString() { + return "Product{" + + "id=" + id + + ", categoryId=" + categoryId + + ", productName=" + productName + + ", unitPrice=" + unitPrice + + "}"; + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapper.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapper.java new file mode 100644 index 0000000..b138ecd --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapper.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; + +/** + * Converts between the Category entity and the CategoryDto. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryMapper { + + public CategoryDto toDto(Category category) { + if (category == null) { + return null; + } + return new CategoryDto(category.getId(), category.getCategoryName()); + } + + public Category toEntity(CategoryDto dto) { + if (dto == null) { + return null; + } + return new Category(dto.getId(), dto.getCategoryName()); + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapper.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapper.java new file mode 100644 index 0000000..c0550b5 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapper.java @@ -0,0 +1,25 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the categories table into a Category entity. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryRowMapper implements RowMapper { + + @Override + public Category mapRow(ResultSet rs, int rowNum) throws SQLException { + return new Category( + rs.getLong("id"), + rs.getString("category_name")); + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapper.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapper.java new file mode 100644 index 0000000..991bf2f --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapper.java @@ -0,0 +1,30 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; + +/** + * Converts between the Product entity and the ProductDto. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductMapper { + + public ProductDto toDto(Product product) { + if (product == null) { + return null; + } + return new ProductDto(product.getId(), product.getCategoryId(), product.getProductName(), + product.getUnitPrice()); + } + + public Product toEntity(ProductDto dto) { + if (dto == null) { + return null; + } + return new Product(dto.getId(), dto.getCategoryId(), dto.getProductName(), dto.getUnitPrice()); + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapper.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapper.java new file mode 100644 index 0000000..acbb972 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapper.java @@ -0,0 +1,27 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the products table into a Product entity. NUMERIC is read with getBigDecimal to keep exact cents. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductRowMapper implements RowMapper { + + @Override + public Product mapRow(ResultSet rs, int rowNum) throws SQLException { + return new Product( + rs.getLong("id"), + rs.getLong("category_id"), + rs.getString("product_name"), + rs.getBigDecimal("unit_price")); + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/CategoryService.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/CategoryService.java new file mode 100644 index 0000000..13a4355 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/CategoryService.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.catalog.service; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; + +/** + * Business operations on categories. Dto types only at this boundary. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface CategoryService { + + /** One page of categories. Page must be >= 0 and size in 1..100, else BusinessRuleException. */ + PageDto list(int page, int size); + + /** Throws ResourceNotFoundException when the id is unknown. */ + CategoryDto get(Long id); + + /** Only the category name of the input is read. Names are trimmed and unique. */ + CategoryDto create(CategoryDto input); + + CategoryDto update(Long id, CategoryDto input); + + /** Refused with BusinessRuleException while the category still has products. */ + void delete(Long id); +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/ProductService.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/ProductService.java new file mode 100644 index 0000000..3b746e6 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/ProductService.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.catalog.service; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; + +/** + * Business operations on products. Dto types only at this boundary. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface ProductService { + + /** One page of products, optionally restricted to a category (categoryId may be null). */ + PageDto list(int page, int size, Long categoryId); + + /** Throws ResourceNotFoundException when the id is unknown. */ + ProductDto get(Long id); + + /** The category must exist. The unit price is normalised to two decimals. */ + ProductDto create(ProductDto input); + + ProductDto update(Long id, ProductDto input); + + /** Refused with BusinessRuleException when orders reference the product. */ + void delete(Long id); +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImpl.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImpl.java new file mode 100644 index 0000000..b9c7aa3 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImpl.java @@ -0,0 +1,125 @@ +package com.edgareldy.springjdbctutorial.core.catalog.service.impl; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +import com.edgareldy.springjdbctutorial.core.catalog.mapper.CategoryMapper; +import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +/** + * Implementation of CategoryService. Declared by @Bean in ServiceConfig (no stereotype) and returned as the interface so the @Transactional advice applies through the proxy. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryServiceImpl implements CategoryService { + + private static final int MAX_PAGE_SIZE = 100; + private static final int MAX_NAME_LENGTH = 100; + + private final CategoryDao categoryDao; + private final ProductDao productDao; + private final CategoryMapper mapper = new CategoryMapper(); + + public CategoryServiceImpl(CategoryDao categoryDao, ProductDao productDao) { + this.categoryDao = categoryDao; + this.productDao = productDao; + } + + @Override + @Transactional(readOnly = true) + public PageDto list(int page, int size) { + if (page < 0) { + throw new BusinessRuleException("Page must be greater than or equal to 0"); + } + if (size < 1 || size > MAX_PAGE_SIZE) { + throw new BusinessRuleException("Size must be between 1 and " + MAX_PAGE_SIZE); + } + List content = new ArrayList<>(); + for (Category category : categoryDao.findPage(page, size)) { + content.add(mapper.toDto(category)); + } + return new PageDto<>(content, page, size, categoryDao.countAll()); + } + + @Override + @Transactional(readOnly = true) + public CategoryDto get(Long id) { + return mapper.toDto(load(id)); + } + + @Override + @Transactional + public CategoryDto create(CategoryDto input) { + String name = cleanName(input.getCategoryName()); + if (categoryDao.existsByCategoryName(name)) { + throw new BusinessRuleException("Category name already exists"); + } + try { + return mapper.toDto(categoryDao.insert(new Category(null, name))); + } catch (DuplicateKeyException e) { + // Lost a race against a concurrent identical insert: the unique constraint decided + throw new BusinessRuleException("Category name already exists", e); + } + } + + @Override + @Transactional + public CategoryDto update(Long id, CategoryDto input) { + Category category = load(id); + String name = cleanName(input.getCategoryName()); + if (!name.equals(category.getCategoryName()) && categoryDao.existsByCategoryName(name)) { + throw new BusinessRuleException("Category name already exists"); + } + category.setCategoryName(name); + try { + categoryDao.update(category); + } catch (DuplicateKeyException e) { + throw new BusinessRuleException("Category name already exists", e); + } + return mapper.toDto(category); + } + + @Override + @Transactional + public void delete(Long id) { + load(id); + if (productDao.countByCategoryId(id) > 0) { + throw new BusinessRuleException("Category still has products"); + } + try { + categoryDao.delete(id); + } catch (DataIntegrityViolationException e) { + // A product was added between the check and the delete: the foreign key refused it + throw new BusinessRuleException("Category still has products", e); + } + } + + private Category load(Long id) { + return categoryDao.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Category not found: " + id)); + } + + private static String cleanName(String raw) { + String name = raw == null ? "" : raw.trim(); + if (name.isEmpty()) { + throw new BusinessRuleException("Category name must not be blank"); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new BusinessRuleException("Category name must be at most " + MAX_NAME_LENGTH + " characters"); + } + return name; + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java new file mode 100644 index 0000000..34db39a --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java @@ -0,0 +1,150 @@ +package com.edgareldy.springjdbctutorial.core.catalog.service.impl; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; +import com.edgareldy.springjdbctutorial.core.catalog.mapper.ProductMapper; +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** + * Implementation of ProductService. Declared by @Bean in ServiceConfig (no stereotype). It reads CategoryDao of the same module to check the category exists. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductServiceImpl implements ProductService { + + private static final int MAX_PAGE_SIZE = 100; + private static final int MAX_NAME_LENGTH = 150; + private static final int PRICE_SCALE = 2; + // NUMERIC(12, 2) holds at most 10 integer digits + private static final BigDecimal MAX_PRICE = new BigDecimal("9999999999.99"); + + private final ProductDao productDao; + private final CategoryDao categoryDao; + private final ProductMapper mapper = new ProductMapper(); + + public ProductServiceImpl(ProductDao productDao, CategoryDao categoryDao) { + this.productDao = productDao; + this.categoryDao = categoryDao; + } + + @Override + @Transactional(readOnly = true) + public PageDto list(int page, int size, Long categoryId) { + if (page < 0) { + throw new BusinessRuleException("Page must be greater than or equal to 0"); + } + if (size < 1 || size > MAX_PAGE_SIZE) { + throw new BusinessRuleException("Size must be between 1 and " + MAX_PAGE_SIZE); + } + List content = new ArrayList<>(); + for (Product product : productDao.findPage(page, size, categoryId)) { + content.add(mapper.toDto(product)); + } + return new PageDto<>(content, page, size, productDao.count(categoryId)); + } + + @Override + @Transactional(readOnly = true) + public ProductDto get(Long id) { + return mapper.toDto(load(id)); + } + + @Override + @Transactional + public ProductDto create(ProductDto input) { + String name = cleanName(input.getProductName()); + BigDecimal price = cleanPrice(input.getUnitPrice()); + requireCategory(input.getCategoryId()); + try { + return mapper.toDto(productDao.insert(new Product(null, input.getCategoryId(), name, price))); + } catch (DataIntegrityViolationException e) { + // The category was deleted between the check above and the insert: the foreign key decided + throw new ResourceNotFoundException("Category not found: " + input.getCategoryId()); + } + } + + @Override + @Transactional + public ProductDto update(Long id, ProductDto input) { + Product product = load(id); + String name = cleanName(input.getProductName()); + BigDecimal price = cleanPrice(input.getUnitPrice()); + requireCategory(input.getCategoryId()); + product.setCategoryId(input.getCategoryId()); + product.setProductName(name); + product.setUnitPrice(price); + try { + productDao.update(product); + } catch (DataIntegrityViolationException e) { + // Same race as in create: the category vanished after the existence check + throw new ResourceNotFoundException("Category not found: " + input.getCategoryId()); + } + return mapper.toDto(product); + } + + @Override + @Transactional + public void delete(Long id) { + load(id); + try { + productDao.delete(id); + } catch (DataIntegrityViolationException e) { + // The orders table references products: its foreign key refuses the delete + throw new BusinessRuleException("Product is referenced by orders", e); + } + } + + private Product load(Long id) { + return productDao.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id)); + } + + private void requireCategory(Long categoryId) { + if (categoryId == null || categoryDao.findById(categoryId).isEmpty()) { + throw new ResourceNotFoundException("Category not found: " + categoryId); + } + } + + private static String cleanName(String raw) { + String name = raw == null ? "" : raw.trim(); + if (name.isEmpty()) { + throw new BusinessRuleException("Product name must not be blank"); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new BusinessRuleException("Product name must be at most " + MAX_NAME_LENGTH + " characters"); + } + return name; + } + + /** Rejects null, negative, too large or more than two decimals, then returns the price with exactly two decimals. */ + private static BigDecimal cleanPrice(BigDecimal raw) { + if (raw == null) { + throw new BusinessRuleException("Unit price is required"); + } + if (raw.signum() < 0) { + throw new BusinessRuleException("Unit price must not be negative"); + } + if (raw.stripTrailingZeros().scale() > PRICE_SCALE) { + throw new BusinessRuleException("Unit price must have at most " + PRICE_SCALE + " decimals"); + } + // Bound first: setScale on a value such as 1E+999999999 would build a gigantic number + if (raw.compareTo(MAX_PRICE) > 0) { + throw new BusinessRuleException("Unit price must not exceed " + MAX_PRICE.toPlainString()); + } + return raw.setScale(PRICE_SCALE); + } +} diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImplTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImplTest.java new file mode 100644 index 0000000..f7e66b9 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImplTest.java @@ -0,0 +1,122 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.catalog.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests CategoryDaoImpl against the real PostgreSQL, with its own fixture file category-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Testcontainers starts a throw-away PostgreSQL in Docker (shared by every test class of the JVM through +// PostgresTestContainer); @DynamicPropertySource injects its url into the Spring Environment before the context +// is built, so DataSourceConfig connects to it and Flyway migrates the REAL schema. @Sql runs this class's own +// fixture file before EACH test so every test starts from the same rows. +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/category-dao-dataset.sql") +class CategoryDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private CategoryDao categoryDao; + + @Test + void _01_ShouldReturnCategory_WhenIdExists() { + assertThat(categoryDao.findById(2L)).get().extracting(Category::getCategoryName).isEqualTo("Games"); + } + + @Test + void _02_ShouldReturnEmpty_WhenIdDoesNotExist() { + assertThat(categoryDao.findById(999L)).isEmpty(); + } + + @Test + void _03_ShouldReturnFirstPageOrderedById_WhenPageIsZero() { + assertThat(categoryDao.findPage(0, 2)).extracting(Category::getId).containsExactly(1L, 2L); + } + + @Test + void _04_ShouldApplyOffsetOfPageTimesSize_WhenPageIsOne() { + assertThat(categoryDao.findPage(1, 2)).extracting(Category::getId).containsExactly(3L); + } + + @Test + void _05_ShouldReturnEmptyList_WhenPageIsBeyondTheLastOne() { + assertThat(categoryDao.findPage(50, 10)).isEmpty(); + } + + @Test + void _06_ShouldCountEveryRow_WhenCountingAll() { + assertThat(categoryDao.countAll()).isEqualTo(3L); + } + + @Test + void _07_ShouldReportExistence_WhenCheckingByName() { + assertThat(categoryDao.existsByCategoryName("Books")).isTrue(); + assertThat(categoryDao.existsByCategoryName("Nothing")).isFalse(); + } + + @Test + void _08_ShouldGenerateIdAndPersistRow_WhenCategoryIsInserted() { + Category created = categoryDao.insert(new Category(null, "Music")); + + assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + assertThat(categoryDao.findById(created.getId())).get().extracting(Category::getCategoryName).isEqualTo("Music"); + assertThat(categoryDao.countAll()).isEqualTo(4L); + } + + @Test + void _09_ShouldThrowDuplicateKeyException_WhenCategoryNameAlreadyExists() { + assertThatThrownBy(() -> categoryDao.insert(new Category(null, "Books"))) + .isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _10_ShouldRenameCategory_WhenUpdated() { + categoryDao.update(new Category(2L, "Video games")); + + assertThat(categoryDao.findById(2L)).get().extracting(Category::getCategoryName).isEqualTo("Video games"); + } + + @Test + void _11_ShouldThrowDuplicateKeyException_WhenRenamingToAnExistingName() { + assertThatThrownBy(() -> categoryDao.update(new Category(2L, "Books"))) + .isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _12_ShouldDeleteCategory_WhenNoProductReferencesIt() { + categoryDao.delete(1L); + + assertThat(categoryDao.findById(1L)).isEmpty(); + assertThat(categoryDao.countAll()).isEqualTo(2L); + } + + @Test + void _13_ShouldThrowDataIntegrityViolationException_WhenAProductStillReferencesTheCategory() { + assertThatThrownBy(() -> categoryDao.delete(3L)).isInstanceOf(DataIntegrityViolationException.class); + + assertThat(categoryDao.findById(3L)).isPresent(); + } +} diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImplTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImplTest.java new file mode 100644 index 0000000..e85892f --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImplTest.java @@ -0,0 +1,145 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.catalog.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests ProductDaoImpl against the real PostgreSQL, with its own fixture file product-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as CategoryDaoImplTest: Testcontainers PostgreSQL migrated by Flyway, url injected by +// @DynamicPropertySource, this class's own fixture loaded by @Sql before each test. +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/product-dao-dataset.sql") +class ProductDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private ProductDao productDao; + + @Test + void _01_ShouldReturnProductWithExactPrice_WhenIdExists() { + Product product = productDao.findById(1L).orElseThrow(); + + assertThat(product.getProductName()).isEqualTo("Novel"); + assertThat(product.getCategoryId()).isEqualTo(1L); + assertThat(product.getUnitPrice()).isEqualTo(new BigDecimal("12.50")); + } + + @Test + void _02_ShouldReturnEmpty_WhenIdDoesNotExist() { + assertThat(productDao.findById(999L)).isEmpty(); + } + + @Test + void _03_ShouldReturnEveryProductOrderedById_WhenNoFilterIsGiven() { + assertThat(productDao.findPage(0, 10, null)).extracting(Product::getId).containsExactly(1L, 2L, 3L, 4L); + } + + @Test + void _04_ShouldApplyOffsetOfPageTimesSize_WhenPagingWithoutFilter() { + assertThat(productDao.findPage(1, 3, null)).extracting(Product::getId).containsExactly(4L); + assertThat(productDao.findPage(9, 3, null)).isEmpty(); + } + + @Test + void _05_ShouldKeepOnlyTheProductsOfThatCategory_WhenFilteringByCategoryId() { + assertThat(productDao.findPage(0, 10, 2L)).extracting(Product::getId).containsExactly(3L, 4L); + assertThat(productDao.findPage(1, 1, 2L)).extracting(Product::getId).containsExactly(4L); + } + + @Test + void _06_ShouldReturnEmptyList_WhenFilteredCategoryHasNoProduct() { + assertThat(productDao.findPage(0, 10, 3L)).isEmpty(); + } + + @Test + void _07_ShouldCountWithAndWithoutFilter_WhenCounting() { + assertThat(productDao.count(null)).isEqualTo(4L); + assertThat(productDao.count(1L)).isEqualTo(2L); + assertThat(productDao.count(3L)).isZero(); + } + + @Test + void _08_ShouldCountTheProductsOfTheCategory_WhenCountingByCategoryId() { + assertThat(productDao.countByCategoryId(2L)).isEqualTo(2L); + assertThat(productDao.countByCategoryId(3L)).isZero(); + } + + @Test + void _09_ShouldGenerateIdAndKeepScale_WhenProductIsInserted() { + Product created = productDao.insert(new Product(null, 3L, "Lamp", new BigDecimal("12.50"))); + + assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + Product reloaded = productDao.findById(created.getId()).orElseThrow(); + // NUMERIC(12, 2) round trip: 12.50 comes back as 12.50 (scale 2), not 12.5 + assertThat(reloaded.getUnitPrice()).isEqualTo(new BigDecimal("12.50")); + assertThat(reloaded.getUnitPrice().scale()).isEqualTo(2); + assertThat(reloaded.getCategoryId()).isEqualTo(3L); + } + + @Test + void _10_ShouldThrowDataIntegrityViolationException_WhenCategoryDoesNotExist() { + assertThatThrownBy(() -> productDao.insert(new Product(null, 999L, "Ghost", new BigDecimal("1.00")))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void _11_ShouldThrowDataIntegrityViolationException_WhenUnitPriceIsNegative() { + assertThatThrownBy(() -> productDao.insert(new Product(null, 1L, "Broken", new BigDecimal("-0.01")))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void _12_ShouldAcceptZeroPrice_WhenInserting() { + Product created = productDao.insert(new Product(null, 1L, "Free", BigDecimal.ZERO)); + + assertThat(productDao.findById(created.getId()).orElseThrow().getUnitPrice()).isEqualByComparingTo("0"); + } + + @Test + void _13_ShouldUpdateEveryColumn_WhenProductIsUpdated() { + productDao.update(new Product(1L, 2L, "Novel 2", new BigDecimal("15.75"))); + + Product reloaded = productDao.findById(1L).orElseThrow(); + assertThat(reloaded.getCategoryId()).isEqualTo(2L); + assertThat(reloaded.getProductName()).isEqualTo("Novel 2"); + assertThat(reloaded.getUnitPrice()).isEqualTo(new BigDecimal("15.75")); + } + + @Test + void _14_ShouldThrowDataIntegrityViolationException_WhenUpdatingToAnUnknownCategory() { + assertThatThrownBy(() -> productDao.update(new Product(1L, 999L, "Novel", new BigDecimal("12.50")))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void _15_ShouldDeleteProduct_WhenIdExists() { + productDao.delete(4L); + + assertThat(productDao.findById(4L)).isEmpty(); + assertThat(productDao.count(null)).isEqualTo(3L); + } +} diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapperTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapperTest.java new file mode 100644 index 0000000..4db78d1 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapperTest.java @@ -0,0 +1,46 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +import org.junit.jupiter.api.Test; + +/** + * Tests CategoryMapper (entity to dto and back) on hand-built objects, no mocks. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CategoryMapperTest { + + private final CategoryMapper mapper = new CategoryMapper(); + + @Test + void _01_ShouldCopyIdAndName_WhenConvertingEntityToDto() { + CategoryDto dto = mapper.toDto(new Category(3L, "Books")); + + assertThat(dto.getId()).isEqualTo(3L); + assertThat(dto.getCategoryName()).isEqualTo("Books"); + } + + @Test + void _02_ShouldReturnNull_WhenEntityIsNull() { + assertThat(mapper.toDto(null)).isNull(); + } + + @Test + void _03_ShouldCopyIdAndName_WhenConvertingDtoToEntity() { + Category category = mapper.toEntity(new CategoryDto(3L, "Books")); + + assertThat(category.getId()).isEqualTo(3L); + assertThat(category.getCategoryName()).isEqualTo("Books"); + } + + @Test + void _04_ShouldReturnNull_WhenDtoIsNull() { + assertThat(mapper.toEntity(null)).isNull(); + } +} diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapperTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapperTest.java new file mode 100644 index 0000000..000f240 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapperTest.java @@ -0,0 +1,37 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +/** + * Tests CategoryRowMapper against a mocked ResultSet, without any database. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CategoryRowMapperTest { + + private final CategoryRowMapper mapper = new CategoryRowMapper(); + + // Mockito.mock(ResultSet.class) is a fake row whose getters return what the test stubs, column by + // column: the test only checks the column-to-field mapping, with no database and no driver. + @Test + void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(7L); + when(rs.getString("category_name")).thenReturn("Books"); + + Category category = mapper.mapRow(rs, 0); + + assertThat(category.getId()).isEqualTo(7L); + assertThat(category.getCategoryName()).isEqualTo("Books"); + } +} diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapperTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapperTest.java new file mode 100644 index 0000000..e267159 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapperTest.java @@ -0,0 +1,51 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +/** + * Tests ProductMapper (entity to dto and back) on hand-built objects, no mocks. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class ProductMapperTest { + + private final ProductMapper mapper = new ProductMapper(); + + @Test + void _01_ShouldCopyEveryField_WhenConvertingEntityToDto() { + ProductDto dto = mapper.toDto(new Product(3L, 2L, "Chess", new BigDecimal("25.99"))); + + assertThat(dto.getId()).isEqualTo(3L); + assertThat(dto.getCategoryId()).isEqualTo(2L); + assertThat(dto.getProductName()).isEqualTo("Chess"); + assertThat(dto.getUnitPrice()).isEqualTo(new BigDecimal("25.99")); + } + + @Test + void _02_ShouldReturnNull_WhenEntityIsNull() { + assertThat(mapper.toDto(null)).isNull(); + } + + @Test + void _03_ShouldCopyEveryField_WhenConvertingDtoToEntity() { + Product product = mapper.toEntity(new ProductDto(3L, 2L, "Chess", new BigDecimal("25.99"))); + + assertThat(product.getId()).isEqualTo(3L); + assertThat(product.getCategoryId()).isEqualTo(2L); + assertThat(product.getProductName()).isEqualTo("Chess"); + assertThat(product.getUnitPrice()).isEqualTo(new BigDecimal("25.99")); + } + + @Test + void _04_ShouldReturnNull_WhenDtoIsNull() { + assertThat(mapper.toEntity(null)).isNull(); + } +} diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapperTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapperTest.java new file mode 100644 index 0000000..d48ca45 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapperTest.java @@ -0,0 +1,41 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +/** + * Tests ProductRowMapper against a mocked ResultSet, including the BigDecimal price column. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class ProductRowMapperTest { + + private final ProductRowMapper mapper = new ProductRowMapper(); + + @Test + void _01_ShouldMapEveryColumnAndKeepThePriceScale_WhenRowIsComplete() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(4L); + when(rs.getLong("category_id")).thenReturn(2L); + when(rs.getString("product_name")).thenReturn("Chess"); + when(rs.getBigDecimal("unit_price")).thenReturn(new BigDecimal("12.50")); + + Product product = mapper.mapRow(rs, 0); + + assertThat(product.getId()).isEqualTo(4L); + assertThat(product.getCategoryId()).isEqualTo(2L); + assertThat(product.getProductName()).isEqualTo("Chess"); + assertThat(product.getUnitPrice()).isEqualTo(new BigDecimal("12.50")); + assertThat(product.getUnitPrice().scale()).isEqualTo(2); + } +} diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImplTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImplTest.java new file mode 100644 index 0000000..09cf379 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImplTest.java @@ -0,0 +1,293 @@ +package com.edgareldy.springjdbctutorial.core.catalog.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; + +/** + * Unit tests of CategoryServiceImpl: both DAOs are Mockito mocks, assertions are made on the dto objects. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// MockitoExtension builds the @Mock fields before each test and fails a test whose stubbing is never used +// (strict stubs), so no test keeps a stub it does not need. No Spring context and no database here. +@ExtendWith(MockitoExtension.class) +class CategoryServiceImplTest { + + @Mock + private CategoryDao categoryDao; + @Mock + private ProductDao productDao; + + private CategoryServiceImpl service() { + return new CategoryServiceImpl(categoryDao, productDao); + } + + private static CategoryDto input(String name) { + return new CategoryDto(null, name); + } + + // ---------------------------------------------------------------- list + + @Test + void _01_ShouldReturnPageOfDtos_WhenListing() { + when(categoryDao.findPage(1, 2)).thenReturn(List.of(new Category(3L, "Toys"))); + when(categoryDao.countAll()).thenReturn(3L); + + PageDto page = service().list(1, 2); + + assertThat(page.getContent()).containsExactly(new CategoryDto(3L, "Toys")); + assertThat(page.getPage()).isEqualTo(1); + assertThat(page.getSize()).isEqualTo(2); + assertThat(page.getTotalElements()).isEqualTo(3L); + } + + @Test + void _02_ShouldReturnEmptyContent_WhenPageIsBeyondTheLastOne() { + when(categoryDao.findPage(50, 10)).thenReturn(List.of()); + when(categoryDao.countAll()).thenReturn(3L); + + PageDto page = service().list(50, 10); + + assertThat(page.getContent()).isEmpty(); + assertThat(page.getTotalElements()).isEqualTo(3L); + } + + @Test + void _03_ShouldThrowBusinessRuleException_WhenPageIsNegative() { + assertThatThrownBy(() -> service().list(-1, 10)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(categoryDao); + } + + @Test + void _04_ShouldThrowBusinessRuleException_WhenSizeIsZero() { + assertThatThrownBy(() -> service().list(0, 0)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(categoryDao); + } + + @Test + void _05_ShouldThrowBusinessRuleException_WhenSizeIsAbove100() { + assertThatThrownBy(() -> service().list(0, 101)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(categoryDao); + } + + @Test + void _06_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() { + when(categoryDao.findPage(0, 1)).thenReturn(List.of()); + when(categoryDao.findPage(0, 100)).thenReturn(List.of()); + + assertThat(service().list(0, 1).getSize()).isEqualTo(1); + assertThat(service().list(0, 100).getSize()).isEqualTo(100); + } + + // ---------------------------------------------------------------- get + + @Test + void _07_ShouldReturnDto_WhenCategoryExists() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + + assertThat(service().get(2L)).isEqualTo(new CategoryDto(2L, "Games")); + } + + @Test + void _08_ShouldThrowResourceNotFoundException_WhenCategoryDoesNotExist() { + when(categoryDao.findById(9L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().get(9L)).isInstanceOf(ResourceNotFoundException.class); + } + + // ---------------------------------------------------------------- create + + @Test + void _09_ShouldInsertTrimmedNameAndReturnDto_WhenNameIsNew() { + when(categoryDao.existsByCategoryName("Books")).thenReturn(false); + when(categoryDao.insert(any(Category.class))).thenAnswer(invocation -> { + Category created = invocation.getArgument(0); + created.setId(11L); + return created; + }); + + CategoryDto created = service().create(input(" Books ")); + + assertThat(created).isEqualTo(new CategoryDto(11L, "Books")); + ArgumentCaptor saved = ArgumentCaptor.forClass(Category.class); + verify(categoryDao).insert(saved.capture()); + assertThat(saved.getValue().getCategoryName()).isEqualTo("Books"); + } + + @Test + void _10_ShouldThrowBusinessRuleException_WhenNameIsBlank() { + assertThatThrownBy(() -> service().create(input(" "))).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(categoryDao); + } + + @Test + void _11_ShouldThrowBusinessRuleException_WhenNameIsNull() { + assertThatThrownBy(() -> service().create(input(null))).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(categoryDao); + } + + @Test + void _12_ShouldThrowBusinessRuleException_WhenNameIsLongerThan100Characters() { + assertThatThrownBy(() -> service().create(input("x".repeat(101)))).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(categoryDao); + } + + @Test + void _13_ShouldAcceptName_WhenExactly100Characters() { + String name = "x".repeat(100); + when(categoryDao.existsByCategoryName(name)).thenReturn(false); + when(categoryDao.insert(any(Category.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + assertThat(service().create(input(name)).getCategoryName()).isEqualTo(name); + } + + @Test + void _14_ShouldThrowBusinessRuleExceptionWithoutInserting_WhenNameAlreadyExists() { + when(categoryDao.existsByCategoryName("Books")).thenReturn(true); + + assertThatThrownBy(() -> service().create(input("Books"))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Category name already exists"); + verify(categoryDao, never()).insert(any()); + } + + @Test + void _15_ShouldTurnDuplicateKeyExceptionIntoBusinessRuleException_WhenAConcurrentInsertWonTheRace() { + DuplicateKeyException race = new DuplicateKeyException("uq_categories_category_name"); + when(categoryDao.existsByCategoryName("Books")).thenReturn(false); + when(categoryDao.insert(any(Category.class))).thenThrow(race); + + assertThatThrownBy(() -> service().create(input("Books"))) + .isInstanceOf(BusinessRuleException.class).hasCause(race); + } + + // ---------------------------------------------------------------- update + + @Test + void _16_ShouldRenameAndReturnDto_WhenNameIsFree() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + when(categoryDao.existsByCategoryName("Video games")).thenReturn(false); + + CategoryDto updated = service().update(2L, input(" Video games ")); + + assertThat(updated).isEqualTo(new CategoryDto(2L, "Video games")); + ArgumentCaptor saved = ArgumentCaptor.forClass(Category.class); + verify(categoryDao).update(saved.capture()); + assertThat(saved.getValue().getCategoryName()).isEqualTo("Video games"); + } + + @Test + void _17_ShouldThrowResourceNotFoundException_WhenUpdatingAnUnknownCategory() { + when(categoryDao.findById(9L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().update(9L, input("Books"))).isInstanceOf(ResourceNotFoundException.class); + verify(categoryDao, never()).update(any()); + } + + @Test + void _18_ShouldAllowIt_WhenTheNameIsUnchanged() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + + CategoryDto updated = service().update(2L, input("Games")); + + assertThat(updated.getCategoryName()).isEqualTo("Games"); + // The name is the category's own: no uniqueness lookup, so it never collides with itself + verify(categoryDao, never()).existsByCategoryName(any()); + verify(categoryDao).update(any(Category.class)); + } + + @Test + void _19_ShouldThrowBusinessRuleException_WhenRenamingToAnotherCategoryName() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + when(categoryDao.existsByCategoryName("Books")).thenReturn(true); + + assertThatThrownBy(() -> service().update(2L, input("Books"))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Category name already exists"); + verify(categoryDao, never()).update(any()); + } + + @Test + void _20_ShouldThrowBusinessRuleException_WhenUpdatedNameIsBlank() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + + assertThatThrownBy(() -> service().update(2L, input(" "))).isInstanceOf(BusinessRuleException.class); + verify(categoryDao, never()).update(any()); + } + + @Test + void _21_ShouldTurnDuplicateKeyExceptionIntoBusinessRuleException_WhenRenameLosesTheRace() { + DuplicateKeyException race = new DuplicateKeyException("uq_categories_category_name"); + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + when(categoryDao.existsByCategoryName("Books")).thenReturn(false); + doThrow(race).when(categoryDao).update(any(Category.class)); + + assertThatThrownBy(() -> service().update(2L, input("Books"))) + .isInstanceOf(BusinessRuleException.class).hasCause(race); + } + + // ---------------------------------------------------------------- delete + + @Test + void _22_ShouldDeleteCategory_WhenNoProductReferencesIt() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + when(productDao.countByCategoryId(2L)).thenReturn(0L); + + service().delete(2L); + + verify(categoryDao).delete(2L); + } + + @Test + void _23_ShouldThrowResourceNotFoundException_WhenDeletingAnUnknownCategory() { + when(categoryDao.findById(9L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().delete(9L)).isInstanceOf(ResourceNotFoundException.class); + verify(categoryDao, never()).delete(any()); + } + + @Test + void _24_ShouldThrowBusinessRuleExceptionWithoutDeleting_WhenProductsStillReferenceTheCategory() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + when(productDao.countByCategoryId(2L)).thenReturn(2L); + + assertThatThrownBy(() -> service().delete(2L)) + .isInstanceOf(BusinessRuleException.class).hasMessage("Category still has products"); + verify(categoryDao, never()).delete(any()); + } + + @Test + void _25_ShouldTurnDataIntegrityViolationIntoBusinessRuleException_WhenAProductAppearsBetweenCheckAndDelete() { + DataIntegrityViolationException race = new DataIntegrityViolationException("fk_products_category"); + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + when(productDao.countByCategoryId(2L)).thenReturn(0L); + doThrow(race).when(categoryDao).delete(2L); + + assertThatThrownBy(() -> service().delete(2L)) + .isInstanceOf(BusinessRuleException.class).hasCause(race); + } +} diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java new file mode 100644 index 0000000..f21d7f2 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java @@ -0,0 +1,345 @@ +package com.edgareldy.springjdbctutorial.core.catalog.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +/** + * Unit tests of ProductServiceImpl: both DAOs are Mockito mocks, assertions are made on the dto objects. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@ExtendWith(MockitoExtension.class) +class ProductServiceImplTest { + + @Mock + private ProductDao productDao; + @Mock + private CategoryDao categoryDao; + + private ProductServiceImpl service() { + return new ProductServiceImpl(productDao, categoryDao); + } + + private static ProductDto input(Long categoryId, String name, String price) { + return new ProductDto(null, categoryId, name, price == null ? null : new BigDecimal(price)); + } + + private void categoryExists(long id) { + when(categoryDao.findById(id)).thenReturn(Optional.of(new Category(id, "Games"))); + } + + private void insertAssignsId(long id) { + when(productDao.insert(any(Product.class))).thenAnswer(invocation -> { + Product created = invocation.getArgument(0); + created.setId(id); + return created; + }); + } + + // ---------------------------------------------------------------- list + + @Test + void _01_ShouldReturnPageOfDtosAndPassTheFilter_WhenListingByCategory() { + when(productDao.findPage(0, 10, 2L)) + .thenReturn(List.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + when(productDao.count(2L)).thenReturn(1L); + + PageDto page = service().list(0, 10, 2L); + + assertThat(page.getContent()).containsExactly(new ProductDto(3L, 2L, "Chess", new BigDecimal("25.99"))); + assertThat(page.getTotalElements()).isEqualTo(1L); + } + + @Test + void _02_ShouldPassNullFilter_WhenNoCategoryIsGiven() { + when(productDao.findPage(0, 20, null)).thenReturn(List.of()); + when(productDao.count(null)).thenReturn(4L); + + PageDto page = service().list(0, 20, null); + + assertThat(page.getContent()).isEmpty(); + assertThat(page.getTotalElements()).isEqualTo(4L); + } + + @Test + void _03_ShouldThrowBusinessRuleException_WhenPageIsNegative() { + assertThatThrownBy(() -> service().list(-1, 10, null)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(productDao); + } + + @Test + void _04_ShouldThrowBusinessRuleException_WhenSizeIsZero() { + assertThatThrownBy(() -> service().list(0, 0, null)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(productDao); + } + + @Test + void _05_ShouldThrowBusinessRuleException_WhenSizeIsAbove100() { + assertThatThrownBy(() -> service().list(0, 101, null)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(productDao); + } + + @Test + void _06_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() { + when(productDao.findPage(0, 1, null)).thenReturn(List.of()); + when(productDao.findPage(0, 100, null)).thenReturn(List.of()); + + assertThat(service().list(0, 1, null).getSize()).isEqualTo(1); + assertThat(service().list(0, 100, null).getSize()).isEqualTo(100); + } + + // ---------------------------------------------------------------- get + + @Test + void _07_ShouldReturnDto_WhenProductExists() { + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + + assertThat(service().get(3L)).isEqualTo(new ProductDto(3L, 2L, "Chess", new BigDecimal("25.99"))); + } + + @Test + void _08_ShouldThrowResourceNotFoundException_WhenProductDoesNotExist() { + when(productDao.findById(9L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().get(9L)).isInstanceOf(ResourceNotFoundException.class); + } + + // ---------------------------------------------------------------- create + + @Test + void _09_ShouldInsertTrimmedNameAndReturnDto_WhenInputIsValid() { + categoryExists(2L); + insertAssignsId(11L); + + ProductDto created = service().create(input(2L, " Chess ", "25.99")); + + assertThat(created).isEqualTo(new ProductDto(11L, 2L, "Chess", new BigDecimal("25.99"))); + ArgumentCaptor saved = ArgumentCaptor.forClass(Product.class); + verify(productDao).insert(saved.capture()); + assertThat(saved.getValue().getProductName()).isEqualTo("Chess"); + assertThat(saved.getValue().getCategoryId()).isEqualTo(2L); + } + + @Test + void _10_ShouldThrowResourceNotFoundException_WhenCategoryDoesNotExist() { + when(categoryDao.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().create(input(999L, "Chess", "25.99"))) + .isInstanceOf(ResourceNotFoundException.class); + verify(productDao, never()).insert(any()); + } + + @Test + void _11_ShouldThrowResourceNotFoundException_WhenCategoryIdIsNull() { + assertThatThrownBy(() -> service().create(input(null, "Chess", "25.99"))) + .isInstanceOf(ResourceNotFoundException.class); + verify(productDao, never()).insert(any()); + } + + @Test + void _12_ShouldThrowBusinessRuleException_WhenNameIsBlank() { + assertThatThrownBy(() -> service().create(input(2L, " ", "25.99"))).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(productDao); + } + + @Test + void _13_ShouldThrowBusinessRuleException_WhenNameIsLongerThan150Characters() { + assertThatThrownBy(() -> service().create(input(2L, "x".repeat(151), "25.99"))) + .isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(productDao); + } + + @Test + void _14_ShouldAcceptName_WhenExactly150Characters() { + categoryExists(2L); + insertAssignsId(11L); + + assertThat(service().create(input(2L, "x".repeat(150), "1.00")).getProductName()).hasSize(150); + } + + @Test + void _15_ShouldThrowBusinessRuleException_WhenPriceIsNull() { + assertThatThrownBy(() -> service().create(input(2L, "Chess", null))).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(productDao); + } + + @Test + void _16_ShouldThrowBusinessRuleException_WhenPriceIsNegative() { + assertThatThrownBy(() -> service().create(input(2L, "Chess", "-0.01"))) + .isInstanceOf(BusinessRuleException.class).hasMessageContaining("negative"); + verifyNoInteractions(productDao); + } + + @Test + void _17_ShouldThrowBusinessRuleException_WhenPriceHasMoreThanTwoDecimals() { + assertThatThrownBy(() -> service().create(input(2L, "Chess", "1.005"))) + .isInstanceOf(BusinessRuleException.class).hasMessageContaining("2 decimals"); + verifyNoInteractions(productDao); + } + + @Test + void _18_ShouldNormaliseScaleToTwo_WhenPriceHasFewerDecimals() { + categoryExists(2L); + insertAssignsId(11L); + + ProductDto created = service().create(input(2L, "Chess", "12.5")); + + assertThat(created.getUnitPrice()).isEqualTo(new BigDecimal("12.50")); + assertThat(created.getUnitPrice().scale()).isEqualTo(2); + } + + @Test + void _19_ShouldAcceptPrice_WhenExtraDecimalsAreOnlyTrailingZeros() { + categoryExists(2L); + insertAssignsId(11L); + + ProductDto created = service().create(input(2L, "Chess", "1.500")); + + assertThat(created.getUnitPrice()).isEqualTo(new BigDecimal("1.50")); + } + + @Test + void _20_ShouldAcceptZeroAndMaximumPrices_WhenOnTheBoundaries() { + categoryExists(2L); + insertAssignsId(11L); + + assertThat(service().create(input(2L, "Free", "0")).getUnitPrice()).isEqualTo(new BigDecimal("0.00")); + assertThat(service().create(input(2L, "Gold", "9999999999.99")).getUnitPrice()) + .isEqualTo(new BigDecimal("9999999999.99")); + } + + @Test + void _21_ShouldThrowBusinessRuleException_WhenPriceExceedsTheColumnCapacity() { + assertThatThrownBy(() -> service().create(input(2L, "Chess", "10000000000.00"))) + .isInstanceOf(BusinessRuleException.class).hasMessageContaining("exceed"); + verifyNoInteractions(productDao); + } + + // ---------------------------------------------------------------- update + + @Test + void _22_ShouldUpdateEveryFieldAndReturnDto_WhenInputIsValid() { + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + categoryExists(1L); + + ProductDto updated = service().update(3L, input(1L, " Atlas ", "30")); + + assertThat(updated).isEqualTo(new ProductDto(3L, 1L, "Atlas", new BigDecimal("30.00"))); + ArgumentCaptor saved = ArgumentCaptor.forClass(Product.class); + verify(productDao).update(saved.capture()); + assertThat(saved.getValue().getCategoryId()).isEqualTo(1L); + assertThat(saved.getValue().getProductName()).isEqualTo("Atlas"); + } + + @Test + void _23_ShouldThrowResourceNotFoundException_WhenUpdatingAnUnknownProduct() { + when(productDao.findById(9L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().update(9L, input(2L, "Chess", "1.00"))) + .isInstanceOf(ResourceNotFoundException.class); + verify(productDao, never()).update(any()); + } + + @Test + void _24_ShouldThrowResourceNotFoundException_WhenUpdatingToAnUnknownCategory() { + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + when(categoryDao.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().update(3L, input(999L, "Chess", "1.00"))) + .isInstanceOf(ResourceNotFoundException.class); + verify(productDao, never()).update(any()); + } + + @Test + void _25_ShouldThrowBusinessRuleException_WhenUpdatedPriceIsInvalid() { + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + + assertThatThrownBy(() -> service().update(3L, input(2L, "Chess", "-1"))) + .isInstanceOf(BusinessRuleException.class); + verify(productDao, never()).update(any()); + } + + // ---------------------------------------------------------------- delete + + @Test + void _26_ShouldDeleteProduct_WhenItExists() { + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + + service().delete(3L); + + verify(productDao).delete(3L); + } + + @Test + void _27_ShouldThrowResourceNotFoundException_WhenDeletingAnUnknownProduct() { + when(productDao.findById(9L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().delete(9L)).isInstanceOf(ResourceNotFoundException.class); + verify(productDao, never()).delete(any()); + } + + @Test + void _28_ShouldTurnDataIntegrityViolationIntoBusinessRuleException_WhenAnOrderReferencesTheProduct() { + DataIntegrityViolationException fk = new DataIntegrityViolationException("fk_orders_product"); + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + doThrow(fk).when(productDao).delete(3L); + + assertThatThrownBy(() -> service().delete(3L)) + .isInstanceOf(BusinessRuleException.class).hasCause(fk); + } + + // Non-regression: the category can vanish between the existence check and the write + @Test + void _29_ShouldThrowResourceNotFoundException_WhenTheCategoryIsDeletedBeforeTheInsert() { + categoryExists(2L); + when(productDao.insert(any(Product.class))).thenThrow(new DataIntegrityViolationException("fk_products_category")); + + assertThatThrownBy(() -> service().create(input(2L, "Chess", "25.99"))) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Category not found: 2"); + } + + @Test + void _30_ShouldThrowResourceNotFoundException_WhenTheCategoryIsDeletedBeforeTheUpdate() { + categoryExists(2L); + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + doThrow(new DataIntegrityViolationException("fk_products_category")).when(productDao).update(any(Product.class)); + + assertThatThrownBy(() -> service().update(3L, input(2L, "Chess", "26.00"))) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Category not found: 2"); + } + + // Non-regression: a huge exponent must be refused by the bound before any setScale expansion + @Test + void _31_ShouldThrowBusinessRuleException_WhenThePriceHasAHugeExponent() { + assertThatThrownBy(() -> service().create(input(2L, "Chess", "1E+999999999"))) + .isInstanceOf(BusinessRuleException.class).hasMessageContaining("must not exceed"); + verify(productDao, never()).insert(any()); + } +} diff --git a/core/catalog/src/test/resources/category-dao-dataset.sql b/core/catalog/src/test/resources/category-dao-dataset.sql new file mode 100644 index 0000000..d668268 --- /dev/null +++ b/core/catalog/src/test/resources/category-dao-dataset.sql @@ -0,0 +1,11 @@ +-- Fixture of CategoryDaoImplTest only. Wipes the catalog tables (products first, by cascade), then loads three +-- categories: Books (1), Games (2) and Toys (3). Category 3 has one product so the foreign key can be exercised. +TRUNCATE TABLE products, categories RESTART IDENTITY CASCADE; + +INSERT INTO categories (id, category_name) VALUES (1, 'Books'), (2, 'Games'), (3, 'Toys'); + +INSERT INTO products (id, category_id, product_name, unit_price) VALUES (1, 3, 'Robot', 19.90); + +-- Explicit ids do not advance the sequences: move them high so generated ids never collide +ALTER SEQUENCE categories_id_seq RESTART WITH 100; +ALTER SEQUENCE products_id_seq RESTART WITH 100; diff --git a/core/catalog/src/test/resources/product-dao-dataset.sql b/core/catalog/src/test/resources/product-dao-dataset.sql new file mode 100644 index 0000000..0852879 --- /dev/null +++ b/core/catalog/src/test/resources/product-dao-dataset.sql @@ -0,0 +1,16 @@ +-- Fixture of ProductDaoImplTest only. Wipes the catalog tables, then loads three categories (Books 1, Games 2 and +-- Empty 3 which holds no product) and four products: Novel (1) and Atlas (2) in Books, Chess (3) and Cards (4) +-- in Games. Ids are ordered so pagination is predictable. +TRUNCATE TABLE products, categories RESTART IDENTITY CASCADE; + +INSERT INTO categories (id, category_name) VALUES (1, 'Books'), (2, 'Games'), (3, 'Empty'); + +INSERT INTO products (id, category_id, product_name, unit_price) VALUES + (1, 1, 'Novel', 12.50), + (2, 1, 'Atlas', 30.00), + (3, 2, 'Chess', 25.99), + (4, 2, 'Cards', 0.00); + +-- Explicit ids do not advance the sequences: move them high so generated ids never collide +ALTER SEQUENCE categories_id_seq RESTART WITH 100; +ALTER SEQUENCE products_id_seq RESTART WITH 100; diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java index 295e659..e1185f7 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -37,7 +37,8 @@ // @Bean methods, so nothing gets wired by accident. @Configuration @EnableWebMvc -@Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class, SecurityConfig.class}) +@Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class, + com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class, SecurityConfig.class}) @ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"}) public class WebMvcConfig implements WebMvcConfigurer { diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CategoryController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CategoryController.java new file mode 100644 index 0000000..6678c55 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CategoryController.java @@ -0,0 +1,77 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService; +import com.edgareldy.springjdbctutorial.ws.converter.CategoryConverter; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.CategoryRequest; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.CategoryResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points to manage categories. No business logic. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@RestController +@RequestMapping("/api/v1/categories") +public class CategoryController { + + private final CategoryService categoryService; + + public CategoryController(CategoryService categoryService) { + this.categoryService = categoryService; + } + + @GetMapping + @PreAuthorize("hasPermission('CATEGORY','READ')") + public ApiResponse> list(@RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ApiResponse.success(CategoryConverter.toPageResponse(categoryService.list(page, size)), "Categories"); + } + + @GetMapping("/{id}") + @PreAuthorize("hasPermission('CATEGORY','READ')") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.success(CategoryConverter.toResponse(categoryService.get(id)), "Category"); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasPermission('CATEGORY','WRITE')") + public ApiResponse create(@Valid @RequestBody CategoryRequest request) { + return ApiResponse.success(CategoryConverter.toResponse(categoryService.create(CategoryConverter.toDto(request))), + "Category created"); + } + + @PutMapping("/{id}") + @PreAuthorize("hasPermission('CATEGORY','WRITE')") + public ApiResponse update(@PathVariable Long id, @Valid @RequestBody CategoryRequest request) { + return ApiResponse.success(CategoryConverter.toResponse(categoryService.update(id, CategoryConverter.toDto(request))), + "Category updated"); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasPermission('CATEGORY','WRITE')") + public ApiResponse delete(@PathVariable Long id) { + categoryService.delete(id); + return ApiResponse.success(null, "Category deleted"); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/ProductController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/ProductController.java new file mode 100644 index 0000000..ced30db --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/ProductController.java @@ -0,0 +1,78 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +import com.edgareldy.springjdbctutorial.ws.converter.ProductConverter; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.ProductRequest; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.ProductResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points to manage products. No business logic. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@RestController +@RequestMapping("/api/v1/products") +public class ProductController { + + private final ProductService productService; + + public ProductController(ProductService productService) { + this.productService = productService; + } + + @GetMapping + @PreAuthorize("hasPermission('PRODUCT','READ')") + public ApiResponse> list(@RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, + @RequestParam(required = false) Long categoryId) { + return ApiResponse.success(ProductConverter.toPageResponse(productService.list(page, size, categoryId)), "Products"); + } + + @GetMapping("/{id}") + @PreAuthorize("hasPermission('PRODUCT','READ')") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.success(ProductConverter.toResponse(productService.get(id)), "Product"); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasPermission('PRODUCT','WRITE')") + public ApiResponse create(@Valid @RequestBody ProductRequest request) { + return ApiResponse.success(ProductConverter.toResponse(productService.create(ProductConverter.toDto(request))), + "Product created"); + } + + @PutMapping("/{id}") + @PreAuthorize("hasPermission('PRODUCT','WRITE')") + public ApiResponse update(@PathVariable Long id, @Valid @RequestBody ProductRequest request) { + return ApiResponse.success(ProductConverter.toResponse(productService.update(id, ProductConverter.toDto(request))), + "Product updated"); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasPermission('PRODUCT','WRITE')") + public ApiResponse delete(@PathVariable Long id) { + productService.delete(id); + return ApiResponse.success(null, "Product deleted"); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CategoryConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CategoryConverter.java new file mode 100644 index 0000000..34bed91 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CategoryConverter.java @@ -0,0 +1,42 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.CategoryRequest; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.CategoryResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts between the core CategoryDto and the category HTTP payloads. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public final class CategoryConverter { + + private CategoryConverter() { + } + + public static CategoryDto toDto(CategoryRequest request) { + CategoryDto dto = new CategoryDto(); + dto.setCategoryName(request.getCategoryName()); + return dto; + } + + public static CategoryResponse toResponse(CategoryDto dto) { + return new CategoryResponse(dto.getId(), dto.getCategoryName()); + } + + public static PageResponse toPageResponse(PageDto page) { + List content = new ArrayList<>(); + for (CategoryDto dto : page.getContent()) { + content.add(toResponse(dto)); + } + return PageResponse.of(content, page.getPage(), page.getSize(), page.getTotalElements()); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/ProductConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/ProductConverter.java new file mode 100644 index 0000000..906f204 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/ProductConverter.java @@ -0,0 +1,44 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.ProductRequest; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.ProductResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts between the core ProductDto and the product HTTP payloads. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public final class ProductConverter { + + private ProductConverter() { + } + + public static ProductDto toDto(ProductRequest request) { + ProductDto dto = new ProductDto(); + dto.setCategoryId(request.getCategoryId()); + dto.setProductName(request.getProductName()); + dto.setUnitPrice(request.getUnitPrice()); + return dto; + } + + public static ProductResponse toResponse(ProductDto dto) { + return new ProductResponse(dto.getId(), dto.getCategoryId(), dto.getProductName(), dto.getUnitPrice()); + } + + public static PageResponse toPageResponse(PageDto page) { + List content = new ArrayList<>(); + for (ProductDto dto : page.getContent()) { + content.add(toResponse(dto)); + } + return PageResponse.of(content, page.getPage(), page.getSize(), page.getTotalElements()); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryRequest.java new file mode 100644 index 0000000..cf4ceff --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryRequest.java @@ -0,0 +1,56 @@ +package com.edgareldy.springjdbctutorial.ws.payload.catalog; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.Objects; + +/** + * Body of the create and update category calls. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryRequest { + + @NotBlank + @Size(max = 100) + private String categoryName; + + public CategoryRequest() { + } + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CategoryRequest that = (CategoryRequest) o; + return Objects.equals(categoryName, that.categoryName); + } + + @Override + public int hashCode() { + return Objects.hash(categoryName); + } + + @Override + public String toString() { + return "CategoryRequest{" + + "categoryName=" + categoryName + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryResponse.java new file mode 100644 index 0000000..8888024 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryResponse.java @@ -0,0 +1,67 @@ +package com.edgareldy.springjdbctutorial.ws.payload.catalog; + +import java.util.Objects; + +/** + * A category as returned by the API. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryResponse { + + private Long id; + + private String categoryName; + + public CategoryResponse() { + } + + public CategoryResponse(Long id, String categoryName) { + this.id = id; + this.categoryName = categoryName; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CategoryResponse that = (CategoryResponse) o; + return Objects.equals(id, that.id) && Objects.equals(categoryName, that.categoryName); + } + + @Override + public int hashCode() { + return Objects.hash(id, categoryName); + } + + @Override + public String toString() { + return "CategoryResponse{" + + "id=" + id + + ", categoryName=" + categoryName + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductRequest.java new file mode 100644 index 0000000..a5cf851 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductRequest.java @@ -0,0 +1,87 @@ +package com.edgareldy.springjdbctutorial.ws.payload.catalog; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Body of the create and update product calls. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductRequest { + + @NotNull + private Long categoryId; + + @NotBlank + @Size(max = 150) + private String productName; + + @NotNull + @DecimalMin("0.00") + @Digits(integer = 10, fraction = 2) + private BigDecimal unitPrice; + + public ProductRequest() { + } + + public Long getCategoryId() { + return categoryId; + } + + public void setCategoryId(Long categoryId) { + this.categoryId = categoryId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public void setUnitPrice(BigDecimal unitPrice) { + this.unitPrice = unitPrice; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ProductRequest that = (ProductRequest) o; + return Objects.equals(categoryId, that.categoryId) && Objects.equals(productName, that.productName) + && Objects.equals(unitPrice, that.unitPrice); + } + + @Override + public int hashCode() { + return Objects.hash(categoryId, productName, unitPrice); + } + + @Override + public String toString() { + return "ProductRequest{" + + "categoryId=" + categoryId + + ", productName=" + productName + + ", unitPrice=" + unitPrice + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductResponse.java new file mode 100644 index 0000000..4773b5e --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductResponse.java @@ -0,0 +1,92 @@ +package com.edgareldy.springjdbctutorial.ws.payload.catalog; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * A product as returned by the API. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductResponse { + + private Long id; + + private Long categoryId; + + private String productName; + + private BigDecimal unitPrice; + + public ProductResponse() { + } + + public ProductResponse(Long id, Long categoryId, String productName, BigDecimal unitPrice) { + this.id = id; + this.categoryId = categoryId; + this.productName = productName; + this.unitPrice = unitPrice; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCategoryId() { + return categoryId; + } + + public void setCategoryId(Long categoryId) { + this.categoryId = categoryId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public void setUnitPrice(BigDecimal unitPrice) { + this.unitPrice = unitPrice; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ProductResponse that = (ProductResponse) o; + return Objects.equals(id, that.id) && Objects.equals(categoryId, that.categoryId) && Objects.equals(productName, that.productName) && Objects.equals(unitPrice, that.unitPrice); + } + + @Override + public int hashCode() { + return Objects.hash(id, categoryId, productName, unitPrice); + } + + @Override + public String toString() { + return "ProductResponse{" + + "id=" + id + + ", categoryId=" + categoryId + + ", productName=" + productName + + ", unitPrice=" + unitPrice + + "}"; + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CatalogFlowIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CatalogFlowIntegrationTest.java new file mode 100644 index 0000000..fb189c8 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CatalogFlowIntegrationTest.java @@ -0,0 +1,385 @@ +package com.edgareldy.springjdbctutorial.ws.integration; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport; +import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport.TestUser; +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * End-to-end catalog flows through the REAL stack (real WebMvcConfig, real services, real PostgreSQL migrated by + * Flyway, real logins): category and product CRUD, the categoryId filter, referenced deletes, unknown categories, + * duplicate names, price edge cases, pagination bounds and permission enforcement. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as RbacFlowIntegrationTest: the whole application context on the shared Testcontainers database. +// Every category and product a test creates is named it-, and tearDown() removes them (plus the users and +// roles of RbacTestSupport), so the classes sharing the one database never see each other's rows. The catalog +// domain has no last-admin style global rule, so no isolation of other holders is needed here. +@SpringJUnitWebConfig(WebMvcConfig.class) +class CatalogFlowIntegrationTest { + + private static final String PREFIX = "it-"; + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private WebApplicationContext context; + @Autowired + private JdbcTemplate jdbcTemplate; + + private MockMvc mockMvc; + private RbacTestSupport support; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + support = new RbacTestSupport(mockMvc, jdbcTemplate); + } + + @AfterEach + void tearDown() { + // Products first: they reference the categories + jdbcTemplate.update("DELETE FROM products WHERE product_name LIKE ?", PREFIX + "%"); + jdbcTemplate.update("DELETE FROM categories WHERE category_name LIKE ?", PREFIX + "%"); + support.close(); + } + + private static String unique() { + return PREFIX + UUID.randomUUID().toString().substring(0, 8); + } + + private static String categoryBody(String name) { + return "{\"categoryName\":\"" + name + "\"}"; + } + + private static String productBody(long categoryId, String name, String price) { + return "{\"categoryId\":" + categoryId + ",\"productName\":\"" + name + "\",\"unitPrice\":" + price + "}"; + } + + private static long idOf(MvcResult result) throws Exception { + return assertSuccess(result, 201).get("data").get("id").asLong(); + } + + private long createCategory(String jwt, String name) throws Exception { + return idOf(support.post(jwt, "/api/v1/categories", categoryBody(name))); + } + + private long createProduct(String jwt, long categoryId, String name, String price) throws Exception { + return idOf(support.post(jwt, "/api/v1/products", productBody(categoryId, name, price))); + } + + private long count(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Long.class, args); + } + + private List productNames(JsonNode page) { + List names = new ArrayList<>(); + for (JsonNode product : page.get("data").get("content")) { + names.add(product.get("productName").asText()); + } + return names; + } + + // ---------------------------------------------------------------- categories + + @Test + void _01_ShouldCreateReadUpdateListAndDeleteACategory_WhenAdminRunsTheFullCrud() throws Exception { + String jwt = support.createAdmin().getJwt(); + String name = unique(); + + long id = createCategory(jwt, " " + name + " "); + + // The stored name is trimmed + JsonNode read = assertSuccess(support.get(jwt, "/api/v1/categories/" + id), 200); + assertThat(read.get("data").get("categoryName").asText()).isEqualTo(name); + + String renamed = unique(); + JsonNode updated = assertSuccess(support.put(jwt, "/api/v1/categories/" + id, categoryBody(renamed)), 200); + assertThat(updated.get("data").get("categoryName").asText()).isEqualTo(renamed); + assertThat(count("SELECT COUNT(*) FROM categories WHERE category_name = ?", renamed)).isEqualTo(1L); + + JsonNode list = assertSuccess(support.get(jwt, "/api/v1/categories?size=100"), 200); + boolean listed = false; + for (JsonNode category : list.get("data").get("content")) { + listed |= category.get("id").asLong() == id; + } + assertThat(listed).isTrue(); + + JsonNode deleted = assertSuccess(support.delete(jwt, "/api/v1/categories/" + id), 200); + assertThat(deleted.get("data") == null || deleted.get("data").isNull()).isTrue(); + assertError(support.get(jwt, "/api/v1/categories/" + id), 404); + } + + @Test + void _02_ShouldReturn422_WhenCategoryNameIsDuplicatedOnCreateOrRename() throws Exception { + String jwt = support.createAdmin().getJwt(); + String first = unique(); + long firstId = createCategory(jwt, first); + long secondId = createCategory(jwt, unique()); + + JsonNode onCreate = assertError(support.post(jwt, "/api/v1/categories", categoryBody(first)), 422); + JsonNode onRename = assertError(support.put(jwt, "/api/v1/categories/" + secondId, categoryBody(first)), 422); + + assertThat(onCreate.get("message").asText()).isEqualTo("Category name already exists"); + assertThat(onRename.get("message").asText()).isEqualTo("Category name already exists"); + // Saving a category under its own name is not a duplicate + assertSuccess(support.put(jwt, "/api/v1/categories/" + firstId, categoryBody(first)), 200); + assertThat(count("SELECT COUNT(*) FROM categories WHERE category_name = ?", first)).isEqualTo(1L); + } + + @Test + void _03_ShouldRefuseTheDeleteWith422ThenSucceed_WhenTheCategoryHasProductsUntilTheyAreDeleted() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryId = createCategory(jwt, unique()); + long productId = createProduct(jwt, categoryId, unique(), "5.00"); + + JsonNode refused = assertError(support.delete(jwt, "/api/v1/categories/" + categoryId), 422); + + assertThat(refused.get("message").asText()).isEqualTo("Category still has products"); + assertSuccess(support.get(jwt, "/api/v1/categories/" + categoryId), 200); + + assertSuccess(support.delete(jwt, "/api/v1/products/" + productId), 200); + assertSuccess(support.delete(jwt, "/api/v1/categories/" + categoryId), 200); + assertError(support.get(jwt, "/api/v1/categories/" + categoryId), 404); + } + + @Test + void _04_ShouldReturn404_WhenCategoryDoesNotExist() throws Exception { + String jwt = support.createAdmin().getJwt(); + + assertError(support.get(jwt, "/api/v1/categories/999999999"), 404); + assertError(support.put(jwt, "/api/v1/categories/999999999", categoryBody(unique())), 404); + assertError(support.delete(jwt, "/api/v1/categories/999999999"), 404); + } + + // ---------------------------------------------------------------- products + + @Test + void _05_ShouldCreateReadUpdateAndDeleteAProductWithExactPrice_WhenAdminRunsTheFullCrud() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryId = createCategory(jwt, unique()); + long otherCategoryId = createCategory(jwt, unique()); + String name = unique(); + + MvcResult created = support.post(jwt, "/api/v1/products", productBody(categoryId, " " + name + " ", "12.5")); + long id = idOf(created); + + // The price comes back with two decimals, and the database holds exactly 12.50 + assertThat(created.getResponse().getContentAsString()).contains("\"unitPrice\":12.50"); + assertThat(jdbcTemplate.queryForObject("SELECT unit_price FROM products WHERE id = ?", BigDecimal.class, id)) + .isEqualTo(new BigDecimal("12.50")); + JsonNode read = assertSuccess(support.get(jwt, "/api/v1/products/" + id), 200); + assertThat(read.get("data").get("productName").asText()).isEqualTo(name); + assertThat(read.get("data").get("categoryId").asLong()).isEqualTo(categoryId); + + String renamed = unique(); + JsonNode updated = assertSuccess(support.put(jwt, "/api/v1/products/" + id, + productBody(otherCategoryId, renamed, "1.5")), 200); + assertThat(updated.get("data").get("categoryId").asLong()).isEqualTo(otherCategoryId); + assertThat(updated.get("data").get("productName").asText()).isEqualTo(renamed); + assertThat(updated.get("data").get("unitPrice").decimalValue()).isEqualByComparingTo("1.50"); + + assertSuccess(support.delete(jwt, "/api/v1/products/" + id), 200); + assertError(support.get(jwt, "/api/v1/products/" + id), 404); + } + + @Test + void _06_ShouldReturn404AndStoreNothing_WhenProductTargetsAnUnknownCategory() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryId = createCategory(jwt, unique()); + String name = unique(); + long id = createProduct(jwt, categoryId, unique(), "3.00"); + + JsonNode onCreate = assertError(support.post(jwt, "/api/v1/products", productBody(999999999L, name, "3.00")), 404); + assertError(support.put(jwt, "/api/v1/products/" + id, productBody(999999999L, name, "3.00")), 404); + + assertThat(onCreate.get("message").asText()).contains("Category not found"); + assertThat(count("SELECT COUNT(*) FROM products WHERE product_name = ?", name)).isZero(); + // The failed update left the product on its category + assertThat(count("SELECT category_id FROM products WHERE id = ?", id)).isEqualTo(categoryId); + } + + @Test + void _07_ShouldReturn404_WhenProductDoesNotExist() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryId = createCategory(jwt, unique()); + + assertError(support.get(jwt, "/api/v1/products/999999999"), 404); + assertError(support.put(jwt, "/api/v1/products/999999999", productBody(categoryId, unique(), "1.00")), 404); + assertError(support.delete(jwt, "/api/v1/products/999999999"), 404); + } + + @Test + void _08_ShouldKeepOnlyTheProductsOfTheCategory_WhenFilteringByCategoryId() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryA = createCategory(jwt, unique()); + long categoryB = createCategory(jwt, unique()); + long emptyCategory = createCategory(jwt, unique()); + String a1 = unique(); + String a2 = unique(); + String b1 = unique(); + createProduct(jwt, categoryA, a1, "1.00"); + createProduct(jwt, categoryA, a2, "2.00"); + createProduct(jwt, categoryB, b1, "3.00"); + + JsonNode filteredA = assertSuccess(support.get(jwt, "/api/v1/products?size=100&categoryId=" + categoryA), 200); + JsonNode pagedA = assertSuccess(support.get(jwt, "/api/v1/products?size=1&page=1&categoryId=" + categoryA), 200); + JsonNode filteredB = assertSuccess(support.get(jwt, "/api/v1/products?categoryId=" + categoryB), 200); + JsonNode filteredEmpty = assertSuccess(support.get(jwt, "/api/v1/products?categoryId=" + emptyCategory), 200); + + assertThat(productNames(filteredA)).containsExactly(a1, a2); + assertThat(filteredA.get("data").get("totalElements").asLong()).isEqualTo(2L); + assertThat(productNames(pagedA)).containsExactly(a2); + assertThat(pagedA.get("data").get("totalElements").asLong()).isEqualTo(2L); + assertThat(pagedA.get("data").get("totalPages").asInt()).isEqualTo(2); + assertThat(productNames(filteredB)).containsExactly(b1); + assertThat(filteredEmpty.get("data").get("content")).isEmpty(); + assertThat(filteredEmpty.get("data").get("totalElements").asLong()).isZero(); + } + + @Test + void _09_ShouldRejectBadPricesWith400AndAcceptTheBoundaries_WhenPricesAreSentThroughHttp() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryId = createCategory(jwt, unique()); + String name = unique(); + + JsonNode negative = assertError(support.post(jwt, "/api/v1/products", productBody(categoryId, name, "-0.01")), 400); + JsonNode threeDecimals = assertError(support.post(jwt, "/api/v1/products", productBody(categoryId, name, "1.005")), 400); + JsonNode huge = assertError(support.post(jwt, "/api/v1/products", productBody(categoryId, name, "10000000000.00")), 400); + JsonNode missing = assertError(support.post(jwt, "/api/v1/products", + "{\"categoryId\":" + categoryId + ",\"productName\":\"" + name + "\"}"), 400); + + assertThat(negative.get("message").asText()).contains("unitPrice: "); + assertThat(threeDecimals.get("message").asText()).contains("unitPrice: "); + assertThat(huge.get("message").asText()).contains("unitPrice: "); + assertThat(missing.get("message").asText()).contains("unitPrice: "); + assertThat(count("SELECT COUNT(*) FROM products WHERE product_name = ?", name)).isZero(); + + // The boundaries themselves are valid: zero, and the largest NUMERIC(12, 2) value + long free = createProduct(jwt, categoryId, unique(), "0"); + long gold = createProduct(jwt, categoryId, unique(), "9999999999.99"); + assertThat(jdbcTemplate.queryForObject("SELECT unit_price FROM products WHERE id = ?", BigDecimal.class, free)) + .isEqualByComparingTo("0.00"); + assertThat(jdbcTemplate.queryForObject("SELECT unit_price FROM products WHERE id = ?", BigDecimal.class, gold)) + .isEqualByComparingTo("9999999999.99"); + } + + // ---------------------------------------------------------------- pagination + + @Test + void _10_ShouldReturn400NamingTheParameter_WhenPageOrSizeIsOutOfBounds() throws Exception { + String jwt = support.createAdmin().getJwt(); + + for (String base : List.of("/api/v1/categories", "/api/v1/products")) { + JsonNode page = assertError(support.get(jwt, base + "?page=-1"), 400); + JsonNode zero = assertError(support.get(jwt, base + "?size=0"), 400); + JsonNode big = assertError(support.get(jwt, base + "?size=101"), 400); + + assertThat(page.get("message").asText()).contains("page: "); + assertThat(zero.get("message").asText()).contains("size: "); + assertThat(big.get("message").asText()).contains("size: "); + assertSuccess(support.get(jwt, base + "?size=1"), 200); + assertSuccess(support.get(jwt, base + "?size=100"), 200); + } + } + + @Test + void _11_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryId = createCategory(jwt, unique()); + createProduct(jwt, categoryId, unique(), "1.00"); + + for (String base : List.of("/api/v1/categories", "/api/v1/products")) { + JsonNode data = assertSuccess(support.get(jwt, base + "?page=100000&size=10"), 200).get("data"); + + assertThat(data.has("content")).isTrue(); + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("page").asInt()).isEqualTo(100000); + assertThat(data.get("totalElements").asLong()).isGreaterThanOrEqualTo(1L); + } + } + + // ---------------------------------------------------------------- permissions + + @Test + void _12_ShouldReturn401_WhenNoTokenIsSent() throws Exception { + for (String url : List.of("/api/v1/categories", "/api/v1/categories/1", "/api/v1/products", "/api/v1/products/1")) { + assertError(support.get(null, url), 401); + } + assertError(support.post(null, "/api/v1/categories", categoryBody(unique())), 401); + assertError(support.post(null, "/api/v1/products", productBody(1L, unique(), "1.00")), 401); + } + + @Test + void _13_ShouldReturn403AndChangeNothing_WhenUserLacksTheWritePermission() throws Exception { + TestUser simple = support.createSimpleUser(); + TestUser readOnly = support.createUser(true, false, support.createRole("CATEGORY:READ", "PRODUCT:READ")); + String admin = support.createAdmin().getJwt(); + long categoryId = createCategory(admin, unique()); + long productId = createProduct(admin, categoryId, unique(), "1.00"); + String name = unique(); + + for (TestUser user : List.of(simple, readOnly)) { + assertError(support.post(user.getJwt(), "/api/v1/categories", categoryBody(name)), 403); + assertError(support.put(user.getJwt(), "/api/v1/categories/" + categoryId, categoryBody(name)), 403); + assertError(support.delete(user.getJwt(), "/api/v1/categories/" + categoryId), 403); + assertError(support.post(user.getJwt(), "/api/v1/products", productBody(categoryId, name, "1.00")), 403); + assertError(support.put(user.getJwt(), "/api/v1/products/" + productId, productBody(categoryId, name, "1.00")), 403); + assertError(support.delete(user.getJwt(), "/api/v1/products/" + productId), 403); + } + assertThat(count("SELECT COUNT(*) FROM categories WHERE category_name = ?", name)).isZero(); + assertThat(count("SELECT COUNT(*) FROM products WHERE id = ?", productId)).isEqualTo(1L); + // The simple user has no read permission either, the read-only user does + assertError(support.get(simple.getJwt(), "/api/v1/categories"), 403); + assertError(support.get(simple.getJwt(), "/api/v1/products"), 403); + assertSuccess(support.get(readOnly.getJwt(), "/api/v1/categories/" + categoryId), 200); + assertSuccess(support.get(readOnly.getJwt(), "/api/v1/products/" + productId), 200); + } + + @Test + void _14_ShouldAllowOnlyWhatEachPermissionGrants_WhenUsersHoldASingleCatalogPermission() throws Exception { + String admin = support.createAdmin().getJwt(); + long categoryId = createCategory(admin, unique()); + TestUser categoryWriter = support.createUser(true, false, support.createRole("CATEGORY:WRITE")); + TestUser productWriter = support.createUser(true, false, support.createRole("PRODUCT:WRITE")); + + // CATEGORY:WRITE alone creates categories but neither reads them nor touches products + long created = idOf(support.post(categoryWriter.getJwt(), "/api/v1/categories", categoryBody(unique()))); + assertThat(created).isPositive(); + assertError(support.get(categoryWriter.getJwt(), "/api/v1/categories/" + created), 403); + assertError(support.post(categoryWriter.getJwt(), "/api/v1/products", productBody(categoryId, unique(), "1.00")), 403); + // PRODUCT:WRITE alone creates products but cannot create a category + createProduct(productWriter.getJwt(), categoryId, unique(), "1.00"); + assertError(support.post(productWriter.getJwt(), "/api/v1/categories", categoryBody(unique())), 403); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCatalogMvcTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCatalogMvcTest.java new file mode 100644 index 0000000..250df27 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCatalogMvcTest.java @@ -0,0 +1,95 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static org.mockito.Mockito.reset; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService; +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +import com.edgareldy.springjdbctutorial.ws.support.JwtTestSupport; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * Common base of the catalog controller tests: builds MockMvc on the shared CatalogMvcTestConfig context, resets + * the mocked services before each test and offers request helpers (signed tokens, JSON bodies). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// MockMvc + springSecurity() runs the real filter chain in front of the DispatcherServlet (see +// AbstractRbacMvcTest): the JWT filter and @PreAuthorize are exercised, only the services are mocked. +@SpringJUnitWebConfig(CatalogMvcTestConfig.class) +abstract class AbstractCatalogMvcTest { + + @Autowired + private WebApplicationContext context; + @Autowired + protected CategoryService categoryService; + @Autowired + protected ProductService productService; + @Autowired + protected AuthService authService; + + protected MockMvc mockMvc; + + @BeforeEach + void setUpMockMvc() { + reset(categoryService, productService, authService); + mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + } + + /** A signed dev-key JWT carrying exactly these permissions. */ + protected static String token(String... permissions) { + return JwtTestSupport.tokenWithPermissions(5L, List.of(permissions)); + } + + /** A signed dev-key JWT carrying every seeded permission except the given one. */ + protected static String tokenWithEverythingExcept(String permission) { + return JwtTestSupport.tokenWithPermissions(5L, + JwtTestSupport.ALL_PERMISSIONS.stream().filter(p -> !p.equals(permission)).toList()); + } + + protected static String bearer(String jwt) { + return "Bearer " + jwt; + } + + protected MvcResult call(MockHttpServletRequestBuilder request) throws Exception { + return mockMvc.perform(request).andReturn(); + } + + protected static MockHttpServletRequestBuilder getWith(String url, String jwt) { + return get(url).header("Authorization", bearer(jwt)); + } + + protected static MockHttpServletRequestBuilder deleteWith(String url, String jwt) { + return delete(url).header("Authorization", bearer(jwt)); + } + + protected static MockHttpServletRequestBuilder patchWith(String url, String jwt) { + return patch(url).header("Authorization", bearer(jwt)); + } + + protected static MockHttpServletRequestBuilder postJson(String url, String jwt, String body) { + return post(url).header("Authorization", bearer(jwt)).contentType(MediaType.APPLICATION_JSON).content(body); + } + + protected static MockHttpServletRequestBuilder putJson(String url, String jwt, String body) { + return put(url).header("Authorization", bearer(jwt)).contentType(MediaType.APPLICATION_JSON).content(body); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogEndpointSecurityTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogEndpointSecurityTest.java new file mode 100644 index 0000000..e220d54 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogEndpointSecurityTest.java @@ -0,0 +1,135 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigDecimal; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +/** + * Permission matrix of the 10 catalog endpoints of the README table, checked through the real filter chain and + * method security: no token gives 401, a revoked token gives 401, a token holding every permission EXCEPT the + * required one (or none at all) gives 403, all as ApiResponse errors and without ever reaching the (mocked) + * services; the right permission alone gives 2xx. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CatalogEndpointSecurityTest extends AbstractCatalogMvcTest { + + private static final String CATEGORY_BODY = "{\"categoryName\":\"Books\"}"; + private static final String PRODUCT_BODY = "{\"categoryId\":1,\"productName\":\"Novel\",\"unitPrice\":12.50}"; + + // Valid bodies on purpose: request validation runs before the method security check, so an invalid body + // would answer 400 and hide the permission rule under test. + static Stream endpoints() { + return Stream.of( + Arguments.of(HttpMethod.GET, "/api/v1/categories", null, "CATEGORY:READ"), + Arguments.of(HttpMethod.GET, "/api/v1/categories/1", null, "CATEGORY:READ"), + Arguments.of(HttpMethod.POST, "/api/v1/categories", CATEGORY_BODY, "CATEGORY:WRITE"), + Arguments.of(HttpMethod.PUT, "/api/v1/categories/1", CATEGORY_BODY, "CATEGORY:WRITE"), + Arguments.of(HttpMethod.DELETE, "/api/v1/categories/1", null, "CATEGORY:WRITE"), + Arguments.of(HttpMethod.GET, "/api/v1/products", null, "PRODUCT:READ"), + Arguments.of(HttpMethod.GET, "/api/v1/products/1", null, "PRODUCT:READ"), + Arguments.of(HttpMethod.POST, "/api/v1/products", PRODUCT_BODY, "PRODUCT:WRITE"), + Arguments.of(HttpMethod.PUT, "/api/v1/products/1", PRODUCT_BODY, "PRODUCT:WRITE"), + Arguments.of(HttpMethod.DELETE, "/api/v1/products/1", null, "PRODUCT:WRITE")); + } + + private static MockHttpServletRequestBuilder request(HttpMethod method, String url, String body, String jwt) { + MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.request(method, url); + if (jwt != null) { + builder.header("Authorization", bearer(jwt)); + } + if (body != null) { + builder.contentType(MediaType.APPLICATION_JSON).content(body); + } + return builder; + } + + @ParameterizedTest(name = "{0} {1}") + @MethodSource("endpoints") + void _01_ShouldReturn401ApiResponse_WhenNoTokenIsSent(HttpMethod method, String url, String body, + String required) throws Exception { + MvcResult result = call(request(method, url, body, null)); + + assertError(result, 401); + verifyNoInteractions(categoryService, productService); + } + + @ParameterizedTest(name = "{0} {1} without {3}") + @MethodSource("endpoints") + void _02_ShouldReturn403ApiResponse_WhenTokenHoldsEveryPermissionExceptTheRequiredOne( + HttpMethod method, String url, String body, String required) throws Exception { + MvcResult result = call(request(method, url, body, tokenWithEverythingExcept(required))); + + JsonNode json = assertError(result, 403); + assertThat(json.get("message").asText()).isEqualTo("Access denied"); + verifyNoInteractions(categoryService, productService); + } + + @ParameterizedTest(name = "{0} {1} with an empty token") + @MethodSource("endpoints") + void _03_ShouldReturn403ApiResponse_WhenTokenHoldsNoPermissionAtAll(HttpMethod method, String url, String body, + String required) throws Exception { + MvcResult result = call(request(method, url, body, token())); + + assertError(result, 403); + verifyNoInteractions(categoryService, productService); + } + + @ParameterizedTest(name = "{0} {1} with a revoked token") + @MethodSource("endpoints") + void _04_ShouldReturn401_WhenTheTokenIsBlacklisted(HttpMethod method, String url, String body, + String required) throws Exception { + String jwt = token(required); + when(authService.isTokenBlacklisted(JwtService.sha256Hex(jwt))).thenReturn(true); + + assertError(call(request(method, url, body, jwt)), 401); + verifyNoInteractions(categoryService, productService); + } + + @ParameterizedTest(name = "{0} {1} with {3}") + @MethodSource("endpoints") + void _05_ShouldNotReturn401Or403_WhenTokenHoldsOnlyTheRequiredPermission(HttpMethod method, String url, + String body, String required) + throws Exception { + // Mocks return null for get/create/update, which would make the controller fail on the converter, so + // give the read paths a real answer; the point of this test is only that the permission opens the door. + when(categoryService.list(0, 20)).thenReturn(new PageDto(List.of(), 0, 20, 0)); + when(categoryService.get(1L)).thenReturn(new CategoryDto(1L, "Books")); + when(categoryService.create(any())).thenReturn(new CategoryDto(1L, "Books")); + when(categoryService.update(eq(1L), any())) + .thenReturn(new CategoryDto(1L, "Books")); + ProductDto product = new ProductDto(1L, 1L, "Novel", new BigDecimal("12.50")); + when(productService.list(0, 20, null)).thenReturn(new PageDto(List.of(), 0, 20, 0)); + when(productService.get(1L)).thenReturn(product); + when(productService.create(any())).thenReturn(product); + when(productService.update(eq(1L), any())) + .thenReturn(product); + + MvcResult result = call(request(method, url, body, token(required))); + + assertThat(result.getResponse().getStatus()).isIn(200, 201); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogMvcTestConfig.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogMvcTestConfig.java new file mode 100644 index 0000000..91f99c9 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogMvcTestConfig.java @@ -0,0 +1,93 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static org.mockito.Mockito.mock; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService; +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +import com.edgareldy.springjdbctutorial.ws.config.SecurityConfig; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.controller.CategoryController; +import com.edgareldy.springjdbctutorial.ws.controller.ProductController; +import com.edgareldy.springjdbctutorial.ws.exception.GlobalExceptionHandler; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Clock; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.validation.Validator; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Shared test context of the catalog controller tests: MVC, the REAL SecurityConfig (JWT filter, method security + * with CustomPermissionEvaluator, 401/403 handlers), the real controllers and advice, and Mockito mocks in + * place of CategoryService and ProductService (no database). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as RbacMvcTestConfig, kept in ws.mvc and never in ws.controller so WebMvcConfig's component scan +// cannot pick it up. The real controllers are declared as beans wired to the mocked services. +@Configuration +@EnableWebMvc +@Import(SecurityConfig.class) +public class CatalogMvcTestConfig implements WebMvcConfigurer { + + private final WebMvcConfig real = new WebMvcConfig(); + + @Bean + Clock clock() { + return Clock.systemUTC(); + } + + @Bean + ObjectMapper objectMapper() { + return real.objectMapper(); + } + + // The security filter chain needs an AuthService to ask "is this token blacklisted?" + @Bean + AuthService authService() { + return mock(AuthService.class); + } + + @Bean + CategoryService categoryService() { + return mock(CategoryService.class); + } + + @Bean + ProductService productService() { + return mock(ProductService.class); + } + + @Bean + CategoryController categoryController(CategoryService categoryService) { + return new CategoryController(categoryService); + } + + @Bean + ProductController productController(ProductService productService) { + return new ProductController(productService); + } + + @Bean + GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Override + public void extendMessageConverters(List> converters) { + real.extendMessageConverters(converters); + } + + @Override + public Validator getValidator() { + return real.getValidator(); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CategoryControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CategoryControllerTest.java new file mode 100644 index 0000000..28c3280 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CategoryControllerTest.java @@ -0,0 +1,268 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +/** + * MockMvc tests of CategoryController with CategoryService mocked but the real security and validation: statuses, + * ApiResponse shape, page/size bounds, per-field body validation and the 404/422 refusals. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CategoryControllerTest extends AbstractCatalogMvcTest { + + private static final String READ = "CATEGORY:READ"; + private static final String WRITE = "CATEGORY:WRITE"; + private static final String VALID_BODY = "{\"categoryName\":\"Books\"}"; + + // ---------------------------------------------------------------- GET /categories + + @Test + void _01_ShouldReturn200WithPageAndDefaults_WhenTokenHoldsCategoryRead() throws Exception { + when(categoryService.list(0, 20)).thenReturn(new PageDto<>(List.of(new CategoryDto(1L, "Books")), 0, 20, 1)); + + JsonNode json = assertSuccess(call(getWith("/api/v1/categories", token(READ))), 200); + + JsonNode data = json.get("data"); + assertThat(data.get("content").get(0).get("id").asLong()).isEqualTo(1L); + assertThat(data.get("content").get(0).get("categoryName").asText()).isEqualTo("Books"); + assertThat(data.get("page").asInt()).isZero(); + assertThat(data.get("size").asInt()).isEqualTo(20); + assertThat(data.get("totalElements").asLong()).isEqualTo(1L); + assertThat(data.get("totalPages").asInt()).isEqualTo(1); + } + + @Test + void _02_ShouldPassPageAndSizeToTheService_WhenTheyAreGiven() throws Exception { + when(categoryService.list(2, 5)).thenReturn(new PageDto<>(List.of(), 2, 5, 11)); + + JsonNode json = assertSuccess(call(getWith("/api/v1/categories?page=2&size=5", token(READ))), 200); + + assertThat(json.get("data").get("page").asInt()).isEqualTo(2); + assertThat(json.get("data").get("size").asInt()).isEqualTo(5); + verify(categoryService).list(2, 5); + } + + @Test + void _03_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + when(categoryService.list(99, 10)).thenReturn(new PageDto<>(List.of(), 99, 10, 3)); + + JsonNode data = assertSuccess(call(getWith("/api/v1/categories?page=99&size=10", token(READ))), 200).get("data"); + + assertThat(data.has("content")).isTrue(); + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("totalElements").asLong()).isEqualTo(3L); + } + + @Test + void _04_ShouldReturn400NamingPage_WhenPageIsNegative() throws Exception { + JsonNode json = assertError(call(getWith("/api/v1/categories?page=-1", token(READ))), 400); + + assertThat(json.get("message").asText()).contains("page: ").doesNotContain("size: "); + verifyNoInteractions(categoryService); + } + + @Test + void _05_ShouldReturn400NamingSize_WhenSizeIsZeroOrAbove100() throws Exception { + JsonNode zero = assertError(call(getWith("/api/v1/categories?size=0", token(READ))), 400); + JsonNode big = assertError(call(getWith("/api/v1/categories?size=101", token(READ))), 400); + + assertThat(zero.get("message").asText()).contains("size: ").doesNotContain("page: "); + assertThat(big.get("message").asText()).contains("size: "); + verifyNoInteractions(categoryService); + } + + @Test + void _06_ShouldReturn400NamingBoth_WhenPageAndSizeAreInvalid() throws Exception { + JsonNode json = assertError(call(getWith("/api/v1/categories?page=-5&size=500", token(READ))), 400); + + assertThat(json.get("message").asText()).contains("page: ").contains("size: "); + } + + @Test + void _07_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() throws Exception { + when(categoryService.list(0, 1)).thenReturn(new PageDto<>(List.of(), 0, 1, 0)); + when(categoryService.list(0, 100)).thenReturn(new PageDto<>(List.of(), 0, 100, 0)); + + assertSuccess(call(getWith("/api/v1/categories?size=1", token(READ))), 200); + assertSuccess(call(getWith("/api/v1/categories?size=100", token(READ))), 200); + } + + // ---------------------------------------------------------------- GET /categories/{id} + + @Test + void _08_ShouldReturn200_WhenCategoryExists() throws Exception { + when(categoryService.get(2L)).thenReturn(new CategoryDto(2L, "Games")); + + JsonNode json = assertSuccess(call(getWith("/api/v1/categories/2", token(READ))), 200); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(2L); + assertThat(json.get("data").get("categoryName").asText()).isEqualTo("Games"); + } + + @Test + void _09_ShouldReturn404ApiResponse_WhenCategoryDoesNotExist() throws Exception { + when(categoryService.get(99L)).thenThrow(new ResourceNotFoundException("Category not found: 99")); + + JsonNode json = assertError(call(getWith("/api/v1/categories/99", token(READ))), 404); + + assertThat(json.get("message").asText()).isEqualTo("Category not found: 99"); + } + + @Test + void _10_ShouldReturn400_WhenCategoryIdIsNotANumber() throws Exception { + assertError(call(getWith("/api/v1/categories/abc", token(READ))), 400); + verifyNoInteractions(categoryService); + } + + // ---------------------------------------------------------------- POST /categories + + @Test + void _11_ShouldReturn201WithCreatedCategory_WhenBodyIsValid() throws Exception { + when(categoryService.create(any(CategoryDto.class))).thenReturn(new CategoryDto(7L, "Books")); + + JsonNode json = assertSuccess(call(postJson("/api/v1/categories", token(WRITE), VALID_BODY)), 201); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(7L); + assertThat(json.get("message").asText()).isEqualTo("Category created"); + ArgumentCaptor sent = ArgumentCaptor.forClass(CategoryDto.class); + verify(categoryService).create(sent.capture()); + assertThat(sent.getValue().getCategoryName()).isEqualTo("Books"); + } + + @Test + void _12_ShouldReturn400NamingTheField_WhenNameIsBlank() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/categories", token(WRITE), "{\"categoryName\":\" \"}")), 400); + + assertThat(json.get("message").asText()).contains("categoryName: "); + verifyNoInteractions(categoryService); + } + + @Test + void _13_ShouldReturn400NamingTheField_WhenNameIsMissingOrTooLong() throws Exception { + JsonNode missing = assertError(call(postJson("/api/v1/categories", token(WRITE), "{}")), 400); + JsonNode tooLong = assertError(call(postJson("/api/v1/categories", token(WRITE), + "{\"categoryName\":\"" + "x".repeat(101) + "\"}")), 400); + + assertThat(missing.get("message").asText()).contains("categoryName: "); + assertThat(tooLong.get("message").asText()).contains("categoryName: "); + verifyNoInteractions(categoryService); + } + + @Test + void _14_ShouldReturn400_WhenBodyIsMalformedJson() throws Exception { + assertError(call(postJson("/api/v1/categories", token(WRITE), "{\"categoryName\":")), 400); + verifyNoInteractions(categoryService); + } + + @Test + void _15_ShouldReturn422_WhenServiceRefusesADuplicateName() throws Exception { + when(categoryService.create(any(CategoryDto.class))) + .thenThrow(new BusinessRuleException("Category name already exists")); + + JsonNode json = assertError(call(postJson("/api/v1/categories", token(WRITE), VALID_BODY)), 422); + + assertThat(json.get("message").asText()).isEqualTo("Category name already exists"); + } + + @Test + void _16_ShouldReturn415_WhenContentTypeIsNotJson() throws Exception { + assertError(call(MockMvcRequestBuilders.post("/api/v1/categories") + .header("Authorization", bearer(token(WRITE))).contentType(MediaType.TEXT_PLAIN).content("Books")), 415); + verifyNoInteractions(categoryService); + } + + // ---------------------------------------------------------------- PUT /categories/{id} + + @Test + void _17_ShouldReturn200WithUpdatedCategory_WhenBodyIsValid() throws Exception { + when(categoryService.update(eq(2L), any(CategoryDto.class))).thenReturn(new CategoryDto(2L, "Books")); + + JsonNode json = assertSuccess(call(putJson("/api/v1/categories/2", token(WRITE), VALID_BODY)), 200); + + assertThat(json.get("data").get("categoryName").asText()).isEqualTo("Books"); + assertThat(json.get("message").asText()).isEqualTo("Category updated"); + } + + @Test + void _18_ShouldReturn404ApiResponse_WhenUpdatingAnUnknownCategory() throws Exception { + when(categoryService.update(eq(99L), any(CategoryDto.class))) + .thenThrow(new ResourceNotFoundException("Category not found: 99")); + + assertError(call(putJson("/api/v1/categories/99", token(WRITE), VALID_BODY)), 404); + } + + @Test + void _19_ShouldReturn422_WhenRenamingToAnExistingName() throws Exception { + when(categoryService.update(eq(2L), any(CategoryDto.class))) + .thenThrow(new BusinessRuleException("Category name already exists")); + + assertError(call(putJson("/api/v1/categories/2", token(WRITE), VALID_BODY)), 422); + } + + @Test + void _20_ShouldReturn400_WhenUpdatedNameIsBlank() throws Exception { + JsonNode json = assertError(call(putJson("/api/v1/categories/2", token(WRITE), "{\"categoryName\":\"\"}")), 400); + + assertThat(json.get("message").asText()).contains("categoryName: "); + verifyNoInteractions(categoryService); + } + + // ---------------------------------------------------------------- DELETE /categories/{id} + + @Test + void _21_ShouldReturn200WithoutData_WhenCategoryIsDeleted() throws Exception { + JsonNode json = assertSuccess(call(deleteWith("/api/v1/categories/2", token(WRITE))), 200); + + assertThat(json.get("message").asText()).isEqualTo("Category deleted"); + assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); + verify(categoryService).delete(2L); + } + + @Test + void _22_ShouldReturn404ApiResponse_WhenDeletingAnUnknownCategory() throws Exception { + doThrow(new ResourceNotFoundException("Category not found: 99")) + .when(categoryService).delete(99L); + + assertError(call(deleteWith("/api/v1/categories/99", token(WRITE))), 404); + } + + @Test + void _23_ShouldReturn422_WhenCategoryStillHasProducts() throws Exception { + doThrow(new BusinessRuleException("Category still has products")) + .when(categoryService).delete(2L); + + JsonNode json = assertError(call(deleteWith("/api/v1/categories/2", token(WRITE))), 422); + + assertThat(json.get("message").asText()).isEqualTo("Category still has products"); + } + + // ---------------------------------------------------------------- unsupported method + + @Test + void _24_ShouldReturn405ApiResponse_WhenPatchIsUsedOnACategory() throws Exception { + assertError(call(patchWith("/api/v1/categories/2", token(WRITE))), 405); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/ProductControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/ProductControllerTest.java new file mode 100644 index 0000000..9ef2a61 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/ProductControllerTest.java @@ -0,0 +1,317 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +/** + * MockMvc tests of ProductController with ProductService mocked but the real security and validation: statuses, + * ApiResponse shape, page/size bounds, the categoryId filter, per-field body validation (price rules included) + * and the 404/422 refusals. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class ProductControllerTest extends AbstractCatalogMvcTest { + + private static final String READ = "PRODUCT:READ"; + private static final String WRITE = "PRODUCT:WRITE"; + private static final String VALID_BODY = "{\"categoryId\":2,\"productName\":\"Chess\",\"unitPrice\":25.99}"; + + private static ProductDto chess() { + return new ProductDto(3L, 2L, "Chess", new BigDecimal("25.99")); + } + + private static String body(String categoryId, String name, String price) { + return "{\"categoryId\":" + categoryId + ",\"productName\":" + name + ",\"unitPrice\":" + price + "}"; + } + + // ---------------------------------------------------------------- GET /products + + @Test + void _01_ShouldReturn200WithPageAndDefaults_WhenTokenHoldsProductRead() throws Exception { + when(productService.list(0, 20, null)).thenReturn(new PageDto<>(List.of(chess()), 0, 20, 1)); + + JsonNode data = assertSuccess(call(getWith("/api/v1/products", token(READ))), 200).get("data"); + + JsonNode first = data.get("content").get(0); + assertThat(first.get("id").asLong()).isEqualTo(3L); + assertThat(first.get("categoryId").asLong()).isEqualTo(2L); + assertThat(first.get("productName").asText()).isEqualTo("Chess"); + assertThat(first.get("unitPrice").decimalValue()).isEqualByComparingTo("25.99"); + assertThat(data.get("page").asInt()).isZero(); + assertThat(data.get("size").asInt()).isEqualTo(20); + assertThat(data.get("totalElements").asLong()).isEqualTo(1L); + } + + @Test + void _02_ShouldPassTheCategoryIdFilterToTheService_WhenItIsGiven() throws Exception { + when(productService.list(1, 5, 2L)).thenReturn(new PageDto<>(List.of(chess()), 1, 5, 6)); + + assertSuccess(call(getWith("/api/v1/products?page=1&size=5&categoryId=2", token(READ))), 200); + + verify(productService).list(1, 5, 2L); + } + + @Test + void _03_ShouldPassNullFilter_WhenCategoryIdIsAbsent() throws Exception { + when(productService.list(0, 20, null)).thenReturn(new PageDto<>(List.of(), 0, 20, 0)); + + assertSuccess(call(getWith("/api/v1/products", token(READ))), 200); + + verify(productService).list(0, 20, null); + } + + @Test + void _04_ShouldReturn400_WhenCategoryIdIsNotANumber() throws Exception { + JsonNode json = assertError(call(getWith("/api/v1/products?categoryId=abc", token(READ))), 400); + + assertThat(json.get("message").asText()).isEqualTo("Invalid value for parameter: categoryId"); + verifyNoInteractions(productService); + } + + @Test + void _05_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + when(productService.list(99, 10, null)).thenReturn(new PageDto<>(List.of(), 99, 10, 4)); + + JsonNode data = assertSuccess(call(getWith("/api/v1/products?page=99&size=10", token(READ))), 200).get("data"); + + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("totalElements").asLong()).isEqualTo(4L); + } + + @Test + void _06_ShouldReturn400NamingPage_WhenPageIsNegative() throws Exception { + JsonNode json = assertError(call(getWith("/api/v1/products?page=-1", token(READ))), 400); + + assertThat(json.get("message").asText()).contains("page: ").doesNotContain("size: "); + verifyNoInteractions(productService); + } + + @Test + void _07_ShouldReturn400NamingSize_WhenSizeIsZeroOrAbove100() throws Exception { + JsonNode zero = assertError(call(getWith("/api/v1/products?size=0", token(READ))), 400); + JsonNode big = assertError(call(getWith("/api/v1/products?size=101", token(READ))), 400); + + assertThat(zero.get("message").asText()).contains("size: ").doesNotContain("page: "); + assertThat(big.get("message").asText()).contains("size: "); + verifyNoInteractions(productService); + } + + @Test + void _08_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() throws Exception { + when(productService.list(0, 1, null)).thenReturn(new PageDto<>(List.of(), 0, 1, 0)); + when(productService.list(0, 100, null)).thenReturn(new PageDto<>(List.of(), 0, 100, 0)); + + assertSuccess(call(getWith("/api/v1/products?size=1", token(READ))), 200); + assertSuccess(call(getWith("/api/v1/products?size=100", token(READ))), 200); + } + + // ---------------------------------------------------------------- GET /products/{id} + + @Test + void _09_ShouldReturn200_WhenProductExists() throws Exception { + when(productService.get(3L)).thenReturn(chess()); + + JsonNode json = assertSuccess(call(getWith("/api/v1/products/3", token(READ))), 200); + + assertThat(json.get("data").get("productName").asText()).isEqualTo("Chess"); + } + + @Test + void _10_ShouldReturn404ApiResponse_WhenProductDoesNotExist() throws Exception { + when(productService.get(99L)).thenThrow(new ResourceNotFoundException("Product not found: 99")); + + JsonNode json = assertError(call(getWith("/api/v1/products/99", token(READ))), 404); + + assertThat(json.get("message").asText()).isEqualTo("Product not found: 99"); + } + + // ---------------------------------------------------------------- POST /products + + @Test + void _11_ShouldReturn201WithCreatedProduct_WhenBodyIsValid() throws Exception { + when(productService.create(any(ProductDto.class))).thenReturn(chess()); + + JsonNode json = assertSuccess(call(postJson("/api/v1/products", token(WRITE), VALID_BODY)), 201); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(3L); + assertThat(json.get("message").asText()).isEqualTo("Product created"); + ArgumentCaptor sent = ArgumentCaptor.forClass(ProductDto.class); + verify(productService).create(sent.capture()); + assertThat(sent.getValue().getCategoryId()).isEqualTo(2L); + assertThat(sent.getValue().getProductName()).isEqualTo("Chess"); + assertThat(sent.getValue().getUnitPrice()).isEqualByComparingTo("25.99"); + } + + @Test + void _12_ShouldReturn400NamingEveryField_WhenBodyIsEmpty() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), "{}")), 400); + + assertThat(json.get("message").asText()).contains("categoryId: ").contains("productName: ") + .contains("unitPrice: "); + verifyNoInteractions(productService); + } + + @Test + void _13_ShouldReturn400NamingOnlyProductName_WhenNameIsBlankOrTooLong() throws Exception { + JsonNode blank = assertError(call(postJson("/api/v1/products", token(WRITE), body("2", "\" \"", "1.00"))), 400); + JsonNode tooLong = assertError(call(postJson("/api/v1/products", token(WRITE), + body("2", "\"" + "x".repeat(151) + "\"", "1.00"))), 400); + + assertThat(blank.get("message").asText()).contains("productName: ").doesNotContain("unitPrice: "); + assertThat(tooLong.get("message").asText()).contains("productName: ").doesNotContain("unitPrice: "); + verifyNoInteractions(productService); + } + + @Test + void _14_ShouldReturn400NamingUnitPrice_WhenPriceIsNegative() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Chess\"", "-0.01"))), 400); + + assertThat(json.get("message").asText()).contains("unitPrice: ").doesNotContain("productName: "); + verifyNoInteractions(productService); + } + + @Test + void _15_ShouldReturn400NamingUnitPrice_WhenPriceHasThreeDecimals() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Chess\"", "1.005"))), 400); + + assertThat(json.get("message").asText()).contains("unitPrice: "); + verifyNoInteractions(productService); + } + + @Test + void _16_ShouldReturn400NamingUnitPrice_WhenPriceHasMoreThanTenIntegerDigits() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), + body("2", "\"Chess\"", "10000000000.00"))), 400); + + assertThat(json.get("message").asText()).contains("unitPrice: "); + verifyNoInteractions(productService); + } + + @Test + void _17_ShouldAcceptTheMaximumPriceAndZero_WhenOnTheBoundaries() throws Exception { + when(productService.create(any(ProductDto.class))).thenReturn(chess()); + + assertSuccess(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Gold\"", "9999999999.99"))), 201); + assertSuccess(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Free\"", "0"))), 201); + } + + @Test + void _18_ShouldReturn400_WhenPriceIsNotANumber() throws Exception { + assertError(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Chess\"", "\"abc\""))), 400); + verifyNoInteractions(productService); + } + + @Test + void _19_ShouldReturn404_WhenServiceReportsAnUnknownCategory() throws Exception { + when(productService.create(any(ProductDto.class))) + .thenThrow(new ResourceNotFoundException("Category not found: 2")); + + JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), VALID_BODY)), 404); + + assertThat(json.get("message").asText()).isEqualTo("Category not found: 2"); + } + + @Test + void _20_ShouldReturn415_WhenContentTypeIsNotJson() throws Exception { + assertError(call(MockMvcRequestBuilders.post("/api/v1/products") + .header("Authorization", bearer(token(WRITE))).contentType(MediaType.TEXT_PLAIN).content("Chess")), 415); + verifyNoInteractions(productService); + } + + // ---------------------------------------------------------------- PUT /products/{id} + + @Test + void _21_ShouldReturn200WithUpdatedProduct_WhenBodyIsValid() throws Exception { + when(productService.update(eq(3L), any(ProductDto.class))).thenReturn(chess()); + + JsonNode json = assertSuccess(call(putJson("/api/v1/products/3", token(WRITE), VALID_BODY)), 200); + + assertThat(json.get("message").asText()).isEqualTo("Product updated"); + assertThat(json.get("data").get("unitPrice").decimalValue()).isEqualByComparingTo("25.99"); + } + + @Test + void _22_ShouldReturn404_WhenUpdatingAnUnknownProductOrCategory() throws Exception { + when(productService.update(eq(99L), any(ProductDto.class))) + .thenThrow(new ResourceNotFoundException("Product not found: 99")); + when(productService.update(eq(3L), any(ProductDto.class))) + .thenThrow(new ResourceNotFoundException("Category not found: 2")); + + assertError(call(putJson("/api/v1/products/99", token(WRITE), VALID_BODY)), 404); + assertError(call(putJson("/api/v1/products/3", token(WRITE), VALID_BODY)), 404); + } + + @Test + void _23_ShouldReturn422_WhenServiceRefusesTheUpdate() throws Exception { + when(productService.update(eq(3L), any(ProductDto.class))) + .thenThrow(new BusinessRuleException("Unit price must not be negative")); + + assertError(call(putJson("/api/v1/products/3", token(WRITE), VALID_BODY)), 422); + } + + @Test + void _24_ShouldReturn400_WhenUpdatedBodyIsInvalid() throws Exception { + JsonNode json = assertError(call(putJson("/api/v1/products/3", token(WRITE), body("null", "\"\"", "-1"))), 400); + + assertThat(json.get("message").asText()).contains("categoryId: ").contains("productName: ") + .contains("unitPrice: "); + verifyNoInteractions(productService); + } + + // ---------------------------------------------------------------- DELETE /products/{id} + + @Test + void _25_ShouldReturn200WithoutData_WhenProductIsDeleted() throws Exception { + JsonNode json = assertSuccess(call(deleteWith("/api/v1/products/3", token(WRITE))), 200); + + assertThat(json.get("message").asText()).isEqualTo("Product deleted"); + assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); + verify(productService).delete(3L); + } + + @Test + void _26_ShouldReturn404ApiResponse_WhenDeletingAnUnknownProduct() throws Exception { + doThrow(new ResourceNotFoundException("Product not found: 99")).when(productService).delete(99L); + + assertError(call(deleteWith("/api/v1/products/99", token(WRITE))), 404); + } + + @Test + void _27_ShouldReturn422_WhenAnOrderReferencesTheProduct() throws Exception { + doThrow(new BusinessRuleException("Product is referenced by orders")).when(productService).delete(3L); + + JsonNode json = assertError(call(deleteWith("/api/v1/products/3", token(WRITE))), 422); + + assertThat(json.get("message").asText()).isEqualTo("Product is referenced by orders"); + } + + // ---------------------------------------------------------------- unsupported method + + @Test + void _28_ShouldReturn405ApiResponse_WhenPatchIsUsedOnAProduct() throws Exception { + assertError(call(patchWith("/api/v1/products/3", token(WRITE))), 405); + } +}