From 26ca072b94cccb3d99245da488a9899151b21980 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 01/17] feat(catalog): add the Category and Product entities and dto classes Category and Product are table-shaped classes documented with the jakarta.persistence annotations, and CategoryDto and ProductDto are the business shapes the services expose. The unit price is a BigDecimal on both sides, never a double, because money must not suffer binary rounding. The two shapes stay separate classes even though their fields match today. --- .../core/catalog/dto/CategoryDto.java | 67 ++++++++++++ .../core/catalog/dto/ProductDto.java | 92 ++++++++++++++++ .../core/catalog/entity/Category.java | 74 +++++++++++++ .../core/catalog/entity/Product.java | 101 ++++++++++++++++++ 4 files changed, 334 insertions(+) create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/CategoryDto.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/ProductDto.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Category.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Product.java 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 + + "}"; + } +} From eef96e844c7baa109e33fc871395417fe75f0a21 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 02/17] feat(catalog): add the RowMappers and entity mappers of the catalog The RowMappers read one row into an entity, using rs.getBigDecimal for the NUMERIC unit price so the exact decimal value survives the round trip. The entity mappers convert between entity and dto in both directions. Neither queries the database or holds any business rule. --- .../core/catalog/mapper/CategoryMapper.java | 29 ++++++++++++++++++ .../catalog/mapper/CategoryRowMapper.java | 25 ++++++++++++++++ .../core/catalog/mapper/ProductMapper.java | 30 +++++++++++++++++++ .../core/catalog/mapper/ProductRowMapper.java | 27 +++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapper.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapper.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapper.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapper.java 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")); + } +} From e7ff607afdc62acd465f775ed05a1a08729088af Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 03/17] feat(catalog): add the category and product DAOs The DAOs write the SQL of the catalog with JdbcTemplate through AbstractDao: paginated reads ordered by id with LIMIT and OFFSET, INSERT ... RETURNING id, updates and deletes. ProductDao chooses between two statements depending on whether a category filter is given, which keeps every query plain and index-friendly, and counts products per category for the delete rule. They work in entity types only and have no stereotype annotation. --- .../core/catalog/dao/CategoryDao.java | 33 ++++++++ .../core/catalog/dao/ProductDao.java | 34 ++++++++ .../catalog/dao/impl/CategoryDaoImpl.java | 68 ++++++++++++++++ .../core/catalog/dao/impl/ProductDaoImpl.java | 78 +++++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/CategoryDao.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/ProductDao.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImpl.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImpl.java 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); + } +} From 3760e00b024ba173a8ffc7298db6c2438d3f9bd7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 04/17] feat(catalog): implement the category and product services The services own the business rules: names are trimmed and bounded, category names are unique, a category that still has products cannot be deleted, a product must reference an existing category, and prices are non-negative decimals with at most two digits after the point, normalised to scale two. Races are handled without an exception translator: a DuplicateKeyException or DataIntegrityViolationException raised by a concurrent change is turned into the same business refusal as the pre-check, so it never leaks as a 500. The service returns dto types and pages through PageDto. --- .../core/catalog/service/CategoryService.java | 29 ++++ .../core/catalog/service/ProductService.java | 29 ++++ .../service/impl/CategoryServiceImpl.java | 125 ++++++++++++++++ .../service/impl/ProductServiceImpl.java | 140 ++++++++++++++++++ 4 files changed, 323 insertions(+) create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/CategoryService.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/ProductService.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImpl.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java 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..bc664f6 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java @@ -0,0 +1,140 @@ +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()); + return mapper.toDto(productDao.insert(new Product(null, input.getCategoryId(), name, price))); + } + + @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); + productDao.update(product); + 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"); + } + BigDecimal price = raw.setScale(PRICE_SCALE); + if (price.compareTo(MAX_PRICE) > 0) { + throw new BusinessRuleException("Unit price must not exceed " + MAX_PRICE.toPlainString()); + } + return price; + } +} From fecd079df68f324c5e5be5f5deadc312758d4402 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 05/17] feat(catalog): wire the catalog DAOs and services explicitly DaoConfig and ServiceConfig declare the beans typed by interface, and ServiceConfig imports DaoConfig so importing it alone is enough. The service beans are proxied for @Transactional through their interfaces. The catalog module needs no configuration of its own beyond these two classes. --- .../core/catalog/config/DaoConfig.java | 31 +++++++++++++++++ .../core/catalog/config/ServiceConfig.java | 34 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/DaoConfig.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/ServiceConfig.java 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); + } +} From 53f4b96e845cb95f46a165e887f79a5d56c358e6 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 06/17] feat(ws): add the catalog payloads and converters Requests carry Bean Validation constraints: bounded names, and for the unit price a non-null decimal that is at least 0.00 with at most ten integer digits and two decimals, so a malformed price is a 400 before it reaches the service. Converters map to and from the core dto classes and build the PageResponse. Payload classes stay distinct from core dto classes. --- .../ws/converter/CategoryConverter.java | 42 +++++++++ .../ws/converter/ProductConverter.java | 44 +++++++++ .../ws/payload/catalog/CategoryRequest.java | 56 +++++++++++ .../ws/payload/catalog/CategoryResponse.java | 67 ++++++++++++++ .../ws/payload/catalog/ProductRequest.java | 87 ++++++++++++++++++ .../ws/payload/catalog/ProductResponse.java | 92 +++++++++++++++++++ 6 files changed, 388 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CategoryConverter.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/ProductConverter.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryResponse.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductResponse.java 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 + + "}"; + } +} From bab6a02792479e18c3591d86e93bf72c26143d8b Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 07/17] feat(ws): expose the category and product endpoints Both controllers follow the README table with declarative @PreAuthorize("hasPermission('CATEGORY'|'PRODUCT', 'READ'|'WRITE')"), only call their service and return ApiResponse for every answer. The list endpoints validate page and size, and the product list accepts an optional categoryId filter. Creations return 201, and there is no business logic in either controller. --- .../ws/controller/CategoryController.java | 77 ++++++++++++++++++ .../ws/controller/ProductController.java | 78 +++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CategoryController.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/ProductController.java 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"); + } +} From f79baf831541e6fc903d0b99ee9d4bd966b6f92e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 08/17] feat(ws): import the catalog module into the shared context The catalog ServiceConfig is added to the WebMvcConfig import list, by fully qualified name because every core module has a class with that simple name. Importing the ServiceConfig alone is enough since it imports its own DaoConfig. --- .../edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 { From d9667888a8a543f5eac8394096418f78abca19e0 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 09/17] build(catalog): add Testcontainers test dependencies to the catalog module The catalog DAO tests run against a real PostgreSQL, so the module declares the Testcontainers JUnit and PostgreSQL artifacts and the common test-jar holding the shared singleton container, all with test scope, exactly like the auth module. They stay out of the parent pom because only modules with DAO tests need them. --- core/catalog/pom.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 + From 1b535c1d6e93a308ffae48752163bd7a8e8e0045 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 10/17] test(catalog): cover the category and product DAOs against PostgreSQL Each DAO test class loads its own dedicated fixture, truncating only the tables it touches. They check CRUD, page ordering and offsets, the optional category filter, counts per category, a duplicate category name, and the constraints owned by the schema: the foreign key on the category, the CHECK on a negative price and the exact NUMERIC round trip of 12.50. A delete refused by a foreign key surfaces as DataIntegrityViolationException, which is what the service relies on for its race handling. --- .../catalog/dao/impl/CategoryDaoImplTest.java | 122 +++++++++++++++ .../catalog/dao/impl/ProductDaoImplTest.java | 145 ++++++++++++++++++ .../test/resources/category-dao-dataset.sql | 11 ++ .../test/resources/product-dao-dataset.sql | 16 ++ 4 files changed, 294 insertions(+) create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImplTest.java create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImplTest.java create mode 100644 core/catalog/src/test/resources/category-dao-dataset.sql create mode 100644 core/catalog/src/test/resources/product-dao-dataset.sql 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/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; From c247a968c1dedc76fd38c7697bfac633516d4cb8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 11/17] test(catalog): test the catalog RowMappers and entity mappers RowMapper tests use a mocked ResultSet, including the BigDecimal unit price, and the entity mappers are checked with hand-built objects and no mocks. They pin the mapping contracts without needing a database. --- .../catalog/mapper/CategoryMapperTest.java | 46 +++++++++++++++++ .../catalog/mapper/CategoryRowMapperTest.java | 37 ++++++++++++++ .../catalog/mapper/ProductMapperTest.java | 51 +++++++++++++++++++ .../catalog/mapper/ProductRowMapperTest.java | 41 +++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapperTest.java create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapperTest.java create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapperTest.java create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapperTest.java 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); + } +} From aeee5c5d3f76fc9292f1d9f53512b61999668916 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 12/17] test(catalog): unit test the category and product services With mocked DAOs the tests cover the page bounds, the 404 cases, name trimming and length limits, duplicate names including the DuplicateKeyException race, and the refusal to delete a category that still has products, both from the count and from the foreign key race. For products they cover an unknown category, every price rule (negative, too many decimals, too large, normalised to two decimals) and the foreign key race on delete. --- .../service/impl/CategoryServiceImplTest.java | 293 ++++++++++++++++ .../service/impl/ProductServiceImplTest.java | 317 ++++++++++++++++++ 2 files changed, 610 insertions(+) create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImplTest.java create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java 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..5987705 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java @@ -0,0 +1,317 @@ +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); + } +} From 2bfaa859cc251f67bcb782ca26f63c3879f99de4 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 13/17] test(ws): test the catalog controllers with MockMvc The real security configuration and a real signed JWT are used with mocked services. Every endpoint is checked for status and JSON shape, validation errors per field including each price case, page and size violations, a page beyond the last that keeps content as an empty array, and the categoryId filter passed to the service. The 404 and 422 mappings, malformed JSON, 415 and 405 are covered too. --- .../ws/mvc/AbstractCatalogMvcTest.java | 95 ++++++ .../ws/mvc/CatalogMvcTestConfig.java | 93 +++++ .../ws/mvc/CategoryControllerTest.java | 268 +++++++++++++++ .../ws/mvc/ProductControllerTest.java | 317 ++++++++++++++++++ 4 files changed, 773 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCatalogMvcTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogMvcTestConfig.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CategoryControllerTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/ProductControllerTest.java 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/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); + } +} From 84fb7dd15e9f02ba493877c699e54ff8af18a632 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 14/17] test(ws): check the permission matrix of the 10 catalog endpoints A parameterized test asserts, for every endpoint, that no token gives 401, that a token holding every other permission or none gives 403, that a blacklisted token gives 401, and that a token holding only the required permission succeeds. It proves each route is guarded by its own CATEGORY or PRODUCT permission. --- .../ws/mvc/CatalogEndpointSecurityTest.java | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogEndpointSecurityTest.java 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); + } +} From 1e85f54b7abe8c6c00a19aaead9897a452e7c3c1 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 15/17] test(ws): run the catalog flows on the real stack Real services and a real database prove the CRUD of categories and products, the category filter, the refusal to delete a category with products followed by success once the product is gone, an unknown category giving 404 with nothing stored, duplicate names giving 422 and the exact stored price. Users holding only CATEGORY:WRITE or only PRODUCT:WRITE, a read-only role and a user without permission are checked for 403. Test rows are named with a random suffix and removed afterwards, so the shared database stays clean. --- .../CatalogFlowIntegrationTest.java | 385 ++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CatalogFlowIntegrationTest.java 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); + } +} From df70d223b15e9048563f05279258c13633a70d76 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 16/17] docs(readme): tick the feature/catalog tasks The five catalog tasks are checked: the entities, dto classes, RowMappers, mappers, DAOs, services and configuration of core/catalog, the category deletion rule, the controllers and converters in ws, the import in WebMvcConfig, and the tests at all five layers with a dedicated fixture per DAO test class. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 From de86bd689ab9dc0d9ddb0ed96f0f66bf465dbd3e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:34:28 +0400 Subject: [PATCH 17/17] fix(catalog): bound the price before scaling and handle a vanished category cleanPrice called setScale before comparing with the maximum, so a value such as 1E+999999999 made BigDecimal build an enormous number before being refused. The bound is now checked first, on the raw value. create and update also checked that the category exists and then wrote, so a category deleted in between made the foreign key raise a DataIntegrityViolationException that became a 500. Both now translate it into the same ResourceNotFoundException as the pre-check. Three unit tests pin these cases. --- .../service/impl/ProductServiceImpl.java | 20 +++++++++---- .../service/impl/ProductServiceImplTest.java | 28 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) 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 index bc664f6..34db39a 100644 --- 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 @@ -69,7 +69,12 @@ public ProductDto create(ProductDto input) { String name = cleanName(input.getProductName()); BigDecimal price = cleanPrice(input.getUnitPrice()); requireCategory(input.getCategoryId()); - return mapper.toDto(productDao.insert(new Product(null, input.getCategoryId(), name, price))); + 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 @@ -82,7 +87,12 @@ public ProductDto update(Long id, ProductDto input) { product.setCategoryId(input.getCategoryId()); product.setProductName(name); product.setUnitPrice(price); - productDao.update(product); + 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); } @@ -131,10 +141,10 @@ private static BigDecimal cleanPrice(BigDecimal raw) { if (raw.stripTrailingZeros().scale() > PRICE_SCALE) { throw new BusinessRuleException("Unit price must have at most " + PRICE_SCALE + " decimals"); } - BigDecimal price = raw.setScale(PRICE_SCALE); - if (price.compareTo(MAX_PRICE) > 0) { + // 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 price; + return raw.setScale(PRICE_SCALE); } } 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 index 5987705..f21d7f2 100644 --- 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 @@ -314,4 +314,32 @@ void _28_ShouldTurnDataIntegrityViolationIntoBusinessRuleException_WhenAnOrderRe 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()); + } }