diff --git a/README.md b/README.md
index 2d336a1..7085ec2 100644
--- a/README.md
+++ b/README.md
@@ -401,11 +401,11 @@ Full CRUD for users, roles, and permissions, still inside `core/auth`. Assignmen
### Tasks
-- [ ] `core/catalog`: `Category`, `Product` entity classes (`@Table("categories")`/`@Table("products")`) and `CategoryDto`/`ProductDto`, `CategoryRowMapper`/`ProductRowMapper` (entity) plus `CategoryMapper`/`ProductMapper` (entity ↔ dto), `CategoryDao`/`ProductDao` + impl, `CategoryService`/`ProductService` + impl, `DaoConfig`/`ServiceConfig`
-- [ ] Business rule: deleting a category that still has products is rejected (`BusinessRuleException` → 422)
-- [ ] `ws`: `CategoryController`, `ProductController`, `CategoryConverter`, `ProductConverter`
-- [ ] `WebMvcConfig` updated to import `core/catalog`'s config
-- [ ] Tests at all five layers - `CategoryDao`/`ProductDao` each with their own fixture dataset, the category-deletion rejection case, a permission-denied case
+- [x] `core/catalog`: `Category`, `Product` entity classes (`@Table("categories")`/`@Table("products")`) and `CategoryDto`/`ProductDto`, `CategoryRowMapper`/`ProductRowMapper` (entity) plus `CategoryMapper`/`ProductMapper` (entity ↔ dto), `CategoryDao`/`ProductDao` + impl, `CategoryService`/`ProductService` + impl, `DaoConfig`/`ServiceConfig`
+- [x] Business rule: deleting a category that still has products is rejected (`BusinessRuleException` → 422)
+- [x] `ws`: `CategoryController`, `ProductController`, `CategoryConverter`, `ProductConverter`
+- [x] `WebMvcConfig` updated to import `core/catalog`'s config
+- [x] Tests at all five layers - `CategoryDao`/`ProductDao` each with their own fixture dataset, the category-deletion rejection case, a permission-denied case
## feature/customer
diff --git a/core/catalog/pom.xml b/core/catalog/pom.xml
index 5c4dca8..7363b0f 100644
--- a/core/catalog/pom.xml
+++ b/core/catalog/pom.xml
@@ -19,5 +19,22 @@
com.edgareldy.springjdbctutorial
common
+
+
+ com.edgareldy.springjdbctutorial
+ common
+ test-jar
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+ org.testcontainers
+ postgresql
+ test
+
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/DaoConfig.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/DaoConfig.java
new file mode 100644
index 0000000..9c70acf
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/DaoConfig.java
@@ -0,0 +1,31 @@
+package com.edgareldy.springjdbctutorial.core.catalog.config;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao;
+import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao;
+import com.edgareldy.springjdbctutorial.core.catalog.dao.impl.CategoryDaoImpl;
+import com.edgareldy.springjdbctutorial.core.catalog.dao.impl.ProductDaoImpl;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+/**
+ * Declares the DAO beans of the catalog module, typed by interface (no stereotype on the implementations).
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+@Configuration
+public class DaoConfig {
+
+ @Bean
+ public CategoryDao categoryDao(JdbcTemplate jdbcTemplate) {
+ return new CategoryDaoImpl(jdbcTemplate);
+ }
+
+ @Bean
+ public ProductDao productDao(JdbcTemplate jdbcTemplate) {
+ return new ProductDaoImpl(jdbcTemplate);
+ }
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/ServiceConfig.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/ServiceConfig.java
new file mode 100644
index 0000000..c323837
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/ServiceConfig.java
@@ -0,0 +1,34 @@
+package com.edgareldy.springjdbctutorial.core.catalog.config;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao;
+import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao;
+import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService;
+import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService;
+import com.edgareldy.springjdbctutorial.core.catalog.service.impl.CategoryServiceImpl;
+import com.edgareldy.springjdbctutorial.core.catalog.service.impl.ProductServiceImpl;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+
+/**
+ * Declares the service beans of the catalog module. Imports its DaoConfig so importing this class alone is enough.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+@Configuration
+@Import(DaoConfig.class)
+public class ServiceConfig {
+
+ @Bean
+ public CategoryService categoryService(CategoryDao categoryDao, ProductDao productDao) {
+ return new CategoryServiceImpl(categoryDao, productDao);
+ }
+
+ @Bean
+ public ProductService productService(ProductDao productDao, CategoryDao categoryDao) {
+ return new ProductServiceImpl(productDao, categoryDao);
+ }
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/CategoryDao.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/CategoryDao.java
new file mode 100644
index 0000000..76b4282
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/CategoryDao.java
@@ -0,0 +1,33 @@
+package com.edgareldy.springjdbctutorial.core.catalog.dao;
+
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Category;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Data access for categories. Entity types only.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public interface CategoryDao {
+
+ /** One page of categories ordered by id. */
+ List findPage(int page, int size);
+
+ long countAll();
+
+ Optional findById(Long id);
+
+ boolean existsByCategoryName(String categoryName);
+
+ /** Inserts the category and returns it with its generated id. */
+ Category insert(Category category);
+
+ void update(Category category);
+
+ void delete(Long id);
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/ProductDao.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/ProductDao.java
new file mode 100644
index 0000000..ff73c2b
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/ProductDao.java
@@ -0,0 +1,34 @@
+package com.edgareldy.springjdbctutorial.core.catalog.dao;
+
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Product;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Data access for products. Entity types only.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public interface ProductDao {
+
+ /** One page of products ordered by id, restricted to a category when categoryId is not null. */
+ List findPage(int page, int size, Long categoryId);
+
+ /** Number of products, restricted to a category when categoryId is not null. */
+ long count(Long categoryId);
+
+ Optional findById(Long id);
+
+ /** Inserts the product and returns it with its generated id. */
+ Product insert(Product product);
+
+ void update(Product product);
+
+ void delete(Long id);
+
+ long countByCategoryId(Long categoryId);
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImpl.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImpl.java
new file mode 100644
index 0000000..749800d
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImpl.java
@@ -0,0 +1,68 @@
+package com.edgareldy.springjdbctutorial.core.catalog.dao.impl;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao;
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Category;
+import com.edgareldy.springjdbctutorial.core.catalog.mapper.CategoryRowMapper;
+import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * JdbcTemplate implementation of CategoryDao. Declared by @Bean in DaoConfig, no stereotype.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class CategoryDaoImpl extends AbstractDao implements CategoryDao {
+
+ private final CategoryRowMapper rowMapper = new CategoryRowMapper();
+
+ public CategoryDaoImpl(JdbcTemplate jdbcTemplate) {
+ super(jdbcTemplate);
+ }
+
+ @Override
+ public List findPage(int page, int size) {
+ return getJdbcTemplate().query(
+ "SELECT id, category_name FROM categories ORDER BY id LIMIT ? OFFSET ?",
+ rowMapper, size, (long) page * size);
+ }
+
+ @Override
+ public long countAll() {
+ return count("SELECT COUNT(*) FROM categories");
+ }
+
+ @Override
+ public Optional findById(Long id) {
+ return findOne("SELECT id, category_name FROM categories WHERE id = ?", rowMapper, id);
+ }
+
+ @Override
+ public boolean existsByCategoryName(String categoryName) {
+ return count("SELECT COUNT(*) FROM categories WHERE category_name = ?", categoryName) > 0;
+ }
+
+ @Override
+ public Category insert(Category category) {
+ long id = insertReturningId("INSERT INTO categories (category_name) VALUES (?) RETURNING id",
+ category.getCategoryName());
+ category.setId(id);
+ return category;
+ }
+
+ @Override
+ public void update(Category category) {
+ getJdbcTemplate().update("UPDATE categories SET category_name = ? WHERE id = ?",
+ category.getCategoryName(), category.getId());
+ }
+
+ @Override
+ public void delete(Long id) {
+ getJdbcTemplate().update("DELETE FROM categories WHERE id = ?", id);
+ }
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImpl.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImpl.java
new file mode 100644
index 0000000..f2f3e44
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImpl.java
@@ -0,0 +1,78 @@
+package com.edgareldy.springjdbctutorial.core.catalog.dao.impl;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao;
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Product;
+import com.edgareldy.springjdbctutorial.core.catalog.mapper.ProductRowMapper;
+import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * JdbcTemplate implementation of ProductDao. Declared by @Bean in DaoConfig, no stereotype.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ProductDaoImpl extends AbstractDao implements ProductDao {
+
+ private static final String COLUMNS = "SELECT id, category_id, product_name, unit_price FROM products";
+
+ private final ProductRowMapper rowMapper = new ProductRowMapper();
+
+ public ProductDaoImpl(JdbcTemplate jdbcTemplate) {
+ super(jdbcTemplate);
+ }
+
+ @Override
+ public List findPage(int page, int size, Long categoryId) {
+ long offset = (long) page * size;
+ if (categoryId == null) {
+ return getJdbcTemplate().query(COLUMNS + " ORDER BY id LIMIT ? OFFSET ?", rowMapper, size, offset);
+ }
+ return getJdbcTemplate().query(COLUMNS + " WHERE category_id = ? ORDER BY id LIMIT ? OFFSET ?",
+ rowMapper, categoryId, size, offset);
+ }
+
+ @Override
+ public long count(Long categoryId) {
+ if (categoryId == null) {
+ return count("SELECT COUNT(*) FROM products");
+ }
+ return countByCategoryId(categoryId);
+ }
+
+ @Override
+ public Optional findById(Long id) {
+ return findOne(COLUMNS + " WHERE id = ?", rowMapper, id);
+ }
+
+ @Override
+ public Product insert(Product product) {
+ long id = insertReturningId(
+ "INSERT INTO products (category_id, product_name, unit_price) VALUES (?, ?, ?) RETURNING id",
+ product.getCategoryId(), product.getProductName(), product.getUnitPrice());
+ product.setId(id);
+ return product;
+ }
+
+ @Override
+ public void update(Product product) {
+ getJdbcTemplate().update(
+ "UPDATE products SET category_id = ?, product_name = ?, unit_price = ? WHERE id = ?",
+ product.getCategoryId(), product.getProductName(), product.getUnitPrice(), product.getId());
+ }
+
+ @Override
+ public void delete(Long id) {
+ getJdbcTemplate().update("DELETE FROM products WHERE id = ?", id);
+ }
+
+ @Override
+ public long countByCategoryId(Long categoryId) {
+ return count("SELECT COUNT(*) FROM products WHERE category_id = ?", categoryId);
+ }
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/CategoryDto.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/CategoryDto.java
new file mode 100644
index 0000000..9c1cfc1
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/CategoryDto.java
@@ -0,0 +1,67 @@
+package com.edgareldy.springjdbctutorial.core.catalog.dto;
+
+import java.util.Objects;
+
+/**
+ * Business shape of a category.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class CategoryDto {
+
+ private Long id;
+
+ private String categoryName;
+
+ public CategoryDto() {
+ }
+
+ public CategoryDto(Long id, String categoryName) {
+ this.id = id;
+ this.categoryName = categoryName;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getCategoryName() {
+ return categoryName;
+ }
+
+ public void setCategoryName(String categoryName) {
+ this.categoryName = categoryName;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ CategoryDto that = (CategoryDto) o;
+ return Objects.equals(id, that.id) && Objects.equals(categoryName, that.categoryName);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, categoryName);
+ }
+
+ @Override
+ public String toString() {
+ return "CategoryDto{" +
+ "id=" + id +
+ ", categoryName=" + categoryName +
+ "}";
+ }
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/ProductDto.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/ProductDto.java
new file mode 100644
index 0000000..e130fcc
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/ProductDto.java
@@ -0,0 +1,92 @@
+package com.edgareldy.springjdbctutorial.core.catalog.dto;
+
+import java.math.BigDecimal;
+import java.util.Objects;
+
+/**
+ * Business shape of a product. The unit price is a decimal with two digits.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ProductDto {
+
+ private Long id;
+
+ private Long categoryId;
+
+ private String productName;
+
+ private BigDecimal unitPrice;
+
+ public ProductDto() {
+ }
+
+ public ProductDto(Long id, Long categoryId, String productName, BigDecimal unitPrice) {
+ this.id = id;
+ this.categoryId = categoryId;
+ this.productName = productName;
+ this.unitPrice = unitPrice;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Long getCategoryId() {
+ return categoryId;
+ }
+
+ public void setCategoryId(Long categoryId) {
+ this.categoryId = categoryId;
+ }
+
+ public String getProductName() {
+ return productName;
+ }
+
+ public void setProductName(String productName) {
+ this.productName = productName;
+ }
+
+ public BigDecimal getUnitPrice() {
+ return unitPrice;
+ }
+
+ public void setUnitPrice(BigDecimal unitPrice) {
+ this.unitPrice = unitPrice;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ ProductDto that = (ProductDto) o;
+ return Objects.equals(id, that.id) && Objects.equals(categoryId, that.categoryId) && Objects.equals(productName, that.productName) && Objects.equals(unitPrice, that.unitPrice);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, categoryId, productName, unitPrice);
+ }
+
+ @Override
+ public String toString() {
+ return "ProductDto{" +
+ "id=" + id +
+ ", categoryId=" + categoryId +
+ ", productName=" + productName +
+ ", unitPrice=" + unitPrice +
+ "}";
+ }
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Category.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Category.java
new file mode 100644
index 0000000..8a49b04
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Category.java
@@ -0,0 +1,74 @@
+package com.edgareldy.springjdbctutorial.core.catalog.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.util.Objects;
+
+/**
+ * Row of the categories table. The jakarta.persistence annotations are documentation only, nothing reads them at runtime.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+@Table(name = "categories")
+public class Category {
+
+ @Id
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "category_name")
+ private String categoryName;
+
+ public Category() {
+ }
+
+ public Category(Long id, String categoryName) {
+ this.id = id;
+ this.categoryName = categoryName;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getCategoryName() {
+ return categoryName;
+ }
+
+ public void setCategoryName(String categoryName) {
+ this.categoryName = categoryName;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Category that = (Category) o;
+ return Objects.equals(id, that.id) && Objects.equals(categoryName, that.categoryName);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, categoryName);
+ }
+
+ @Override
+ public String toString() {
+ return "Category{" +
+ "id=" + id +
+ ", categoryName=" + categoryName +
+ "}";
+ }
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Product.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Product.java
new file mode 100644
index 0000000..725080a
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Product.java
@@ -0,0 +1,101 @@
+package com.edgareldy.springjdbctutorial.core.catalog.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.math.BigDecimal;
+import java.util.Objects;
+
+/**
+ * Row of the products table. The jakarta.persistence annotations are documentation only, nothing reads them at runtime.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+@Table(name = "products")
+public class Product {
+
+ @Id
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "category_id")
+ private Long categoryId;
+
+ @Column(name = "product_name")
+ private String productName;
+
+ @Column(name = "unit_price")
+ private BigDecimal unitPrice;
+
+ public Product() {
+ }
+
+ public Product(Long id, Long categoryId, String productName, BigDecimal unitPrice) {
+ this.id = id;
+ this.categoryId = categoryId;
+ this.productName = productName;
+ this.unitPrice = unitPrice;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Long getCategoryId() {
+ return categoryId;
+ }
+
+ public void setCategoryId(Long categoryId) {
+ this.categoryId = categoryId;
+ }
+
+ public String getProductName() {
+ return productName;
+ }
+
+ public void setProductName(String productName) {
+ this.productName = productName;
+ }
+
+ public BigDecimal getUnitPrice() {
+ return unitPrice;
+ }
+
+ public void setUnitPrice(BigDecimal unitPrice) {
+ this.unitPrice = unitPrice;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Product that = (Product) o;
+ return Objects.equals(id, that.id) && Objects.equals(categoryId, that.categoryId) && Objects.equals(productName, that.productName) && Objects.equals(unitPrice, that.unitPrice);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, categoryId, productName, unitPrice);
+ }
+
+ @Override
+ public String toString() {
+ return "Product{" +
+ "id=" + id +
+ ", categoryId=" + categoryId +
+ ", productName=" + productName +
+ ", unitPrice=" + unitPrice +
+ "}";
+ }
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapper.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapper.java
new file mode 100644
index 0000000..b138ecd
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapper.java
@@ -0,0 +1,29 @@
+package com.edgareldy.springjdbctutorial.core.catalog.mapper;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto;
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Category;
+
+/**
+ * Converts between the Category entity and the CategoryDto.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class CategoryMapper {
+
+ public CategoryDto toDto(Category category) {
+ if (category == null) {
+ return null;
+ }
+ return new CategoryDto(category.getId(), category.getCategoryName());
+ }
+
+ public Category toEntity(CategoryDto dto) {
+ if (dto == null) {
+ return null;
+ }
+ return new Category(dto.getId(), dto.getCategoryName());
+ }
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapper.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapper.java
new file mode 100644
index 0000000..c0550b5
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapper.java
@@ -0,0 +1,25 @@
+package com.edgareldy.springjdbctutorial.core.catalog.mapper;
+
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Category;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+/**
+ * Converts one row of the categories table into a Category entity.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class CategoryRowMapper implements RowMapper {
+
+ @Override
+ public Category mapRow(ResultSet rs, int rowNum) throws SQLException {
+ return new Category(
+ rs.getLong("id"),
+ rs.getString("category_name"));
+ }
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapper.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapper.java
new file mode 100644
index 0000000..991bf2f
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapper.java
@@ -0,0 +1,30 @@
+package com.edgareldy.springjdbctutorial.core.catalog.mapper;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto;
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Product;
+
+/**
+ * Converts between the Product entity and the ProductDto.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ProductMapper {
+
+ public ProductDto toDto(Product product) {
+ if (product == null) {
+ return null;
+ }
+ return new ProductDto(product.getId(), product.getCategoryId(), product.getProductName(),
+ product.getUnitPrice());
+ }
+
+ public Product toEntity(ProductDto dto) {
+ if (dto == null) {
+ return null;
+ }
+ return new Product(dto.getId(), dto.getCategoryId(), dto.getProductName(), dto.getUnitPrice());
+ }
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapper.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapper.java
new file mode 100644
index 0000000..acbb972
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapper.java
@@ -0,0 +1,27 @@
+package com.edgareldy.springjdbctutorial.core.catalog.mapper;
+
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Product;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+/**
+ * Converts one row of the products table into a Product entity. NUMERIC is read with getBigDecimal to keep exact cents.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ProductRowMapper implements RowMapper {
+
+ @Override
+ public Product mapRow(ResultSet rs, int rowNum) throws SQLException {
+ return new Product(
+ rs.getLong("id"),
+ rs.getLong("category_id"),
+ rs.getString("product_name"),
+ rs.getBigDecimal("unit_price"));
+ }
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/CategoryService.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/CategoryService.java
new file mode 100644
index 0000000..13a4355
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/CategoryService.java
@@ -0,0 +1,29 @@
+package com.edgareldy.springjdbctutorial.core.catalog.service;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto;
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+
+/**
+ * Business operations on categories. Dto types only at this boundary.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public interface CategoryService {
+
+ /** One page of categories. Page must be >= 0 and size in 1..100, else BusinessRuleException. */
+ PageDto list(int page, int size);
+
+ /** Throws ResourceNotFoundException when the id is unknown. */
+ CategoryDto get(Long id);
+
+ /** Only the category name of the input is read. Names are trimmed and unique. */
+ CategoryDto create(CategoryDto input);
+
+ CategoryDto update(Long id, CategoryDto input);
+
+ /** Refused with BusinessRuleException while the category still has products. */
+ void delete(Long id);
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/ProductService.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/ProductService.java
new file mode 100644
index 0000000..3b746e6
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/ProductService.java
@@ -0,0 +1,29 @@
+package com.edgareldy.springjdbctutorial.core.catalog.service;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto;
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+
+/**
+ * Business operations on products. Dto types only at this boundary.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public interface ProductService {
+
+ /** One page of products, optionally restricted to a category (categoryId may be null). */
+ PageDto list(int page, int size, Long categoryId);
+
+ /** Throws ResourceNotFoundException when the id is unknown. */
+ ProductDto get(Long id);
+
+ /** The category must exist. The unit price is normalised to two decimals. */
+ ProductDto create(ProductDto input);
+
+ ProductDto update(Long id, ProductDto input);
+
+ /** Refused with BusinessRuleException when orders reference the product. */
+ void delete(Long id);
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImpl.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImpl.java
new file mode 100644
index 0000000..b9c7aa3
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImpl.java
@@ -0,0 +1,125 @@
+package com.edgareldy.springjdbctutorial.core.catalog.service.impl;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao;
+import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao;
+import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto;
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Category;
+import com.edgareldy.springjdbctutorial.core.catalog.mapper.CategoryMapper;
+import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService;
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException;
+import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implementation of CategoryService. Declared by @Bean in ServiceConfig (no stereotype) and returned as the interface so the @Transactional advice applies through the proxy.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class CategoryServiceImpl implements CategoryService {
+
+ private static final int MAX_PAGE_SIZE = 100;
+ private static final int MAX_NAME_LENGTH = 100;
+
+ private final CategoryDao categoryDao;
+ private final ProductDao productDao;
+ private final CategoryMapper mapper = new CategoryMapper();
+
+ public CategoryServiceImpl(CategoryDao categoryDao, ProductDao productDao) {
+ this.categoryDao = categoryDao;
+ this.productDao = productDao;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public PageDto list(int page, int size) {
+ if (page < 0) {
+ throw new BusinessRuleException("Page must be greater than or equal to 0");
+ }
+ if (size < 1 || size > MAX_PAGE_SIZE) {
+ throw new BusinessRuleException("Size must be between 1 and " + MAX_PAGE_SIZE);
+ }
+ List content = new ArrayList<>();
+ for (Category category : categoryDao.findPage(page, size)) {
+ content.add(mapper.toDto(category));
+ }
+ return new PageDto<>(content, page, size, categoryDao.countAll());
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public CategoryDto get(Long id) {
+ return mapper.toDto(load(id));
+ }
+
+ @Override
+ @Transactional
+ public CategoryDto create(CategoryDto input) {
+ String name = cleanName(input.getCategoryName());
+ if (categoryDao.existsByCategoryName(name)) {
+ throw new BusinessRuleException("Category name already exists");
+ }
+ try {
+ return mapper.toDto(categoryDao.insert(new Category(null, name)));
+ } catch (DuplicateKeyException e) {
+ // Lost a race against a concurrent identical insert: the unique constraint decided
+ throw new BusinessRuleException("Category name already exists", e);
+ }
+ }
+
+ @Override
+ @Transactional
+ public CategoryDto update(Long id, CategoryDto input) {
+ Category category = load(id);
+ String name = cleanName(input.getCategoryName());
+ if (!name.equals(category.getCategoryName()) && categoryDao.existsByCategoryName(name)) {
+ throw new BusinessRuleException("Category name already exists");
+ }
+ category.setCategoryName(name);
+ try {
+ categoryDao.update(category);
+ } catch (DuplicateKeyException e) {
+ throw new BusinessRuleException("Category name already exists", e);
+ }
+ return mapper.toDto(category);
+ }
+
+ @Override
+ @Transactional
+ public void delete(Long id) {
+ load(id);
+ if (productDao.countByCategoryId(id) > 0) {
+ throw new BusinessRuleException("Category still has products");
+ }
+ try {
+ categoryDao.delete(id);
+ } catch (DataIntegrityViolationException e) {
+ // A product was added between the check and the delete: the foreign key refused it
+ throw new BusinessRuleException("Category still has products", e);
+ }
+ }
+
+ private Category load(Long id) {
+ return categoryDao.findById(id)
+ .orElseThrow(() -> new ResourceNotFoundException("Category not found: " + id));
+ }
+
+ private static String cleanName(String raw) {
+ String name = raw == null ? "" : raw.trim();
+ if (name.isEmpty()) {
+ throw new BusinessRuleException("Category name must not be blank");
+ }
+ if (name.length() > MAX_NAME_LENGTH) {
+ throw new BusinessRuleException("Category name must be at most " + MAX_NAME_LENGTH + " characters");
+ }
+ return name;
+ }
+}
diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java
new file mode 100644
index 0000000..34db39a
--- /dev/null
+++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java
@@ -0,0 +1,150 @@
+package com.edgareldy.springjdbctutorial.core.catalog.service.impl;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao;
+import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao;
+import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto;
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Product;
+import com.edgareldy.springjdbctutorial.core.catalog.mapper.ProductMapper;
+import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService;
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException;
+import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implementation of ProductService. Declared by @Bean in ServiceConfig (no stereotype). It reads CategoryDao of the same module to check the category exists.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ProductServiceImpl implements ProductService {
+
+ private static final int MAX_PAGE_SIZE = 100;
+ private static final int MAX_NAME_LENGTH = 150;
+ private static final int PRICE_SCALE = 2;
+ // NUMERIC(12, 2) holds at most 10 integer digits
+ private static final BigDecimal MAX_PRICE = new BigDecimal("9999999999.99");
+
+ private final ProductDao productDao;
+ private final CategoryDao categoryDao;
+ private final ProductMapper mapper = new ProductMapper();
+
+ public ProductServiceImpl(ProductDao productDao, CategoryDao categoryDao) {
+ this.productDao = productDao;
+ this.categoryDao = categoryDao;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public PageDto list(int page, int size, Long categoryId) {
+ if (page < 0) {
+ throw new BusinessRuleException("Page must be greater than or equal to 0");
+ }
+ if (size < 1 || size > MAX_PAGE_SIZE) {
+ throw new BusinessRuleException("Size must be between 1 and " + MAX_PAGE_SIZE);
+ }
+ List content = new ArrayList<>();
+ for (Product product : productDao.findPage(page, size, categoryId)) {
+ content.add(mapper.toDto(product));
+ }
+ return new PageDto<>(content, page, size, productDao.count(categoryId));
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public ProductDto get(Long id) {
+ return mapper.toDto(load(id));
+ }
+
+ @Override
+ @Transactional
+ public ProductDto create(ProductDto input) {
+ String name = cleanName(input.getProductName());
+ BigDecimal price = cleanPrice(input.getUnitPrice());
+ requireCategory(input.getCategoryId());
+ try {
+ return mapper.toDto(productDao.insert(new Product(null, input.getCategoryId(), name, price)));
+ } catch (DataIntegrityViolationException e) {
+ // The category was deleted between the check above and the insert: the foreign key decided
+ throw new ResourceNotFoundException("Category not found: " + input.getCategoryId());
+ }
+ }
+
+ @Override
+ @Transactional
+ public ProductDto update(Long id, ProductDto input) {
+ Product product = load(id);
+ String name = cleanName(input.getProductName());
+ BigDecimal price = cleanPrice(input.getUnitPrice());
+ requireCategory(input.getCategoryId());
+ product.setCategoryId(input.getCategoryId());
+ product.setProductName(name);
+ product.setUnitPrice(price);
+ try {
+ productDao.update(product);
+ } catch (DataIntegrityViolationException e) {
+ // Same race as in create: the category vanished after the existence check
+ throw new ResourceNotFoundException("Category not found: " + input.getCategoryId());
+ }
+ return mapper.toDto(product);
+ }
+
+ @Override
+ @Transactional
+ public void delete(Long id) {
+ load(id);
+ try {
+ productDao.delete(id);
+ } catch (DataIntegrityViolationException e) {
+ // The orders table references products: its foreign key refuses the delete
+ throw new BusinessRuleException("Product is referenced by orders", e);
+ }
+ }
+
+ private Product load(Long id) {
+ return productDao.findById(id)
+ .orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id));
+ }
+
+ private void requireCategory(Long categoryId) {
+ if (categoryId == null || categoryDao.findById(categoryId).isEmpty()) {
+ throw new ResourceNotFoundException("Category not found: " + categoryId);
+ }
+ }
+
+ private static String cleanName(String raw) {
+ String name = raw == null ? "" : raw.trim();
+ if (name.isEmpty()) {
+ throw new BusinessRuleException("Product name must not be blank");
+ }
+ if (name.length() > MAX_NAME_LENGTH) {
+ throw new BusinessRuleException("Product name must be at most " + MAX_NAME_LENGTH + " characters");
+ }
+ return name;
+ }
+
+ /** Rejects null, negative, too large or more than two decimals, then returns the price with exactly two decimals. */
+ private static BigDecimal cleanPrice(BigDecimal raw) {
+ if (raw == null) {
+ throw new BusinessRuleException("Unit price is required");
+ }
+ if (raw.signum() < 0) {
+ throw new BusinessRuleException("Unit price must not be negative");
+ }
+ if (raw.stripTrailingZeros().scale() > PRICE_SCALE) {
+ throw new BusinessRuleException("Unit price must have at most " + PRICE_SCALE + " decimals");
+ }
+ // Bound first: setScale on a value such as 1E+999999999 would build a gigantic number
+ if (raw.compareTo(MAX_PRICE) > 0) {
+ throw new BusinessRuleException("Unit price must not exceed " + MAX_PRICE.toPlainString());
+ }
+ return raw.setScale(PRICE_SCALE);
+ }
+}
diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImplTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImplTest.java
new file mode 100644
index 0000000..f7e66b9
--- /dev/null
+++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImplTest.java
@@ -0,0 +1,122 @@
+package com.edgareldy.springjdbctutorial.core.catalog.dao.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.edgareldy.springjdbctutorial.core.catalog.config.DaoConfig;
+import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao;
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Category;
+import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig;
+import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+/**
+ * Tests CategoryDaoImpl against the real PostgreSQL, with its own fixture file category-dao-dataset.sql.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+// Testcontainers starts a throw-away PostgreSQL in Docker (shared by every test class of the JVM through
+// PostgresTestContainer); @DynamicPropertySource injects its url into the Spring Environment before the context
+// is built, so DataSourceConfig connects to it and Flyway migrates the REAL schema. @Sql runs this class's own
+// fixture file before EACH test so every test starts from the same rows.
+@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class})
+@Sql("/category-dao-dataset.sql")
+class CategoryDaoImplTest {
+
+ @DynamicPropertySource
+ static void datasource(DynamicPropertyRegistry registry) {
+ PostgresTestContainer.register(registry);
+ }
+
+ @Autowired
+ private CategoryDao categoryDao;
+
+ @Test
+ void _01_ShouldReturnCategory_WhenIdExists() {
+ assertThat(categoryDao.findById(2L)).get().extracting(Category::getCategoryName).isEqualTo("Games");
+ }
+
+ @Test
+ void _02_ShouldReturnEmpty_WhenIdDoesNotExist() {
+ assertThat(categoryDao.findById(999L)).isEmpty();
+ }
+
+ @Test
+ void _03_ShouldReturnFirstPageOrderedById_WhenPageIsZero() {
+ assertThat(categoryDao.findPage(0, 2)).extracting(Category::getId).containsExactly(1L, 2L);
+ }
+
+ @Test
+ void _04_ShouldApplyOffsetOfPageTimesSize_WhenPageIsOne() {
+ assertThat(categoryDao.findPage(1, 2)).extracting(Category::getId).containsExactly(3L);
+ }
+
+ @Test
+ void _05_ShouldReturnEmptyList_WhenPageIsBeyondTheLastOne() {
+ assertThat(categoryDao.findPage(50, 10)).isEmpty();
+ }
+
+ @Test
+ void _06_ShouldCountEveryRow_WhenCountingAll() {
+ assertThat(categoryDao.countAll()).isEqualTo(3L);
+ }
+
+ @Test
+ void _07_ShouldReportExistence_WhenCheckingByName() {
+ assertThat(categoryDao.existsByCategoryName("Books")).isTrue();
+ assertThat(categoryDao.existsByCategoryName("Nothing")).isFalse();
+ }
+
+ @Test
+ void _08_ShouldGenerateIdAndPersistRow_WhenCategoryIsInserted() {
+ Category created = categoryDao.insert(new Category(null, "Music"));
+
+ assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L);
+ assertThat(categoryDao.findById(created.getId())).get().extracting(Category::getCategoryName).isEqualTo("Music");
+ assertThat(categoryDao.countAll()).isEqualTo(4L);
+ }
+
+ @Test
+ void _09_ShouldThrowDuplicateKeyException_WhenCategoryNameAlreadyExists() {
+ assertThatThrownBy(() -> categoryDao.insert(new Category(null, "Books")))
+ .isInstanceOf(DuplicateKeyException.class);
+ }
+
+ @Test
+ void _10_ShouldRenameCategory_WhenUpdated() {
+ categoryDao.update(new Category(2L, "Video games"));
+
+ assertThat(categoryDao.findById(2L)).get().extracting(Category::getCategoryName).isEqualTo("Video games");
+ }
+
+ @Test
+ void _11_ShouldThrowDuplicateKeyException_WhenRenamingToAnExistingName() {
+ assertThatThrownBy(() -> categoryDao.update(new Category(2L, "Books")))
+ .isInstanceOf(DuplicateKeyException.class);
+ }
+
+ @Test
+ void _12_ShouldDeleteCategory_WhenNoProductReferencesIt() {
+ categoryDao.delete(1L);
+
+ assertThat(categoryDao.findById(1L)).isEmpty();
+ assertThat(categoryDao.countAll()).isEqualTo(2L);
+ }
+
+ @Test
+ void _13_ShouldThrowDataIntegrityViolationException_WhenAProductStillReferencesTheCategory() {
+ assertThatThrownBy(() -> categoryDao.delete(3L)).isInstanceOf(DataIntegrityViolationException.class);
+
+ assertThat(categoryDao.findById(3L)).isPresent();
+ }
+}
diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImplTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImplTest.java
new file mode 100644
index 0000000..e85892f
--- /dev/null
+++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImplTest.java
@@ -0,0 +1,145 @@
+package com.edgareldy.springjdbctutorial.core.catalog.dao.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.edgareldy.springjdbctutorial.core.catalog.config.DaoConfig;
+import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao;
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Product;
+import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig;
+import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer;
+import java.math.BigDecimal;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+/**
+ * Tests ProductDaoImpl against the real PostgreSQL, with its own fixture file product-dao-dataset.sql.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+// Same recipe as CategoryDaoImplTest: Testcontainers PostgreSQL migrated by Flyway, url injected by
+// @DynamicPropertySource, this class's own fixture loaded by @Sql before each test.
+@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class})
+@Sql("/product-dao-dataset.sql")
+class ProductDaoImplTest {
+
+ @DynamicPropertySource
+ static void datasource(DynamicPropertyRegistry registry) {
+ PostgresTestContainer.register(registry);
+ }
+
+ @Autowired
+ private ProductDao productDao;
+
+ @Test
+ void _01_ShouldReturnProductWithExactPrice_WhenIdExists() {
+ Product product = productDao.findById(1L).orElseThrow();
+
+ assertThat(product.getProductName()).isEqualTo("Novel");
+ assertThat(product.getCategoryId()).isEqualTo(1L);
+ assertThat(product.getUnitPrice()).isEqualTo(new BigDecimal("12.50"));
+ }
+
+ @Test
+ void _02_ShouldReturnEmpty_WhenIdDoesNotExist() {
+ assertThat(productDao.findById(999L)).isEmpty();
+ }
+
+ @Test
+ void _03_ShouldReturnEveryProductOrderedById_WhenNoFilterIsGiven() {
+ assertThat(productDao.findPage(0, 10, null)).extracting(Product::getId).containsExactly(1L, 2L, 3L, 4L);
+ }
+
+ @Test
+ void _04_ShouldApplyOffsetOfPageTimesSize_WhenPagingWithoutFilter() {
+ assertThat(productDao.findPage(1, 3, null)).extracting(Product::getId).containsExactly(4L);
+ assertThat(productDao.findPage(9, 3, null)).isEmpty();
+ }
+
+ @Test
+ void _05_ShouldKeepOnlyTheProductsOfThatCategory_WhenFilteringByCategoryId() {
+ assertThat(productDao.findPage(0, 10, 2L)).extracting(Product::getId).containsExactly(3L, 4L);
+ assertThat(productDao.findPage(1, 1, 2L)).extracting(Product::getId).containsExactly(4L);
+ }
+
+ @Test
+ void _06_ShouldReturnEmptyList_WhenFilteredCategoryHasNoProduct() {
+ assertThat(productDao.findPage(0, 10, 3L)).isEmpty();
+ }
+
+ @Test
+ void _07_ShouldCountWithAndWithoutFilter_WhenCounting() {
+ assertThat(productDao.count(null)).isEqualTo(4L);
+ assertThat(productDao.count(1L)).isEqualTo(2L);
+ assertThat(productDao.count(3L)).isZero();
+ }
+
+ @Test
+ void _08_ShouldCountTheProductsOfTheCategory_WhenCountingByCategoryId() {
+ assertThat(productDao.countByCategoryId(2L)).isEqualTo(2L);
+ assertThat(productDao.countByCategoryId(3L)).isZero();
+ }
+
+ @Test
+ void _09_ShouldGenerateIdAndKeepScale_WhenProductIsInserted() {
+ Product created = productDao.insert(new Product(null, 3L, "Lamp", new BigDecimal("12.50")));
+
+ assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L);
+ Product reloaded = productDao.findById(created.getId()).orElseThrow();
+ // NUMERIC(12, 2) round trip: 12.50 comes back as 12.50 (scale 2), not 12.5
+ assertThat(reloaded.getUnitPrice()).isEqualTo(new BigDecimal("12.50"));
+ assertThat(reloaded.getUnitPrice().scale()).isEqualTo(2);
+ assertThat(reloaded.getCategoryId()).isEqualTo(3L);
+ }
+
+ @Test
+ void _10_ShouldThrowDataIntegrityViolationException_WhenCategoryDoesNotExist() {
+ assertThatThrownBy(() -> productDao.insert(new Product(null, 999L, "Ghost", new BigDecimal("1.00"))))
+ .isInstanceOf(DataIntegrityViolationException.class);
+ }
+
+ @Test
+ void _11_ShouldThrowDataIntegrityViolationException_WhenUnitPriceIsNegative() {
+ assertThatThrownBy(() -> productDao.insert(new Product(null, 1L, "Broken", new BigDecimal("-0.01"))))
+ .isInstanceOf(DataIntegrityViolationException.class);
+ }
+
+ @Test
+ void _12_ShouldAcceptZeroPrice_WhenInserting() {
+ Product created = productDao.insert(new Product(null, 1L, "Free", BigDecimal.ZERO));
+
+ assertThat(productDao.findById(created.getId()).orElseThrow().getUnitPrice()).isEqualByComparingTo("0");
+ }
+
+ @Test
+ void _13_ShouldUpdateEveryColumn_WhenProductIsUpdated() {
+ productDao.update(new Product(1L, 2L, "Novel 2", new BigDecimal("15.75")));
+
+ Product reloaded = productDao.findById(1L).orElseThrow();
+ assertThat(reloaded.getCategoryId()).isEqualTo(2L);
+ assertThat(reloaded.getProductName()).isEqualTo("Novel 2");
+ assertThat(reloaded.getUnitPrice()).isEqualTo(new BigDecimal("15.75"));
+ }
+
+ @Test
+ void _14_ShouldThrowDataIntegrityViolationException_WhenUpdatingToAnUnknownCategory() {
+ assertThatThrownBy(() -> productDao.update(new Product(1L, 999L, "Novel", new BigDecimal("12.50"))))
+ .isInstanceOf(DataIntegrityViolationException.class);
+ }
+
+ @Test
+ void _15_ShouldDeleteProduct_WhenIdExists() {
+ productDao.delete(4L);
+
+ assertThat(productDao.findById(4L)).isEmpty();
+ assertThat(productDao.count(null)).isEqualTo(3L);
+ }
+}
diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapperTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapperTest.java
new file mode 100644
index 0000000..4db78d1
--- /dev/null
+++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapperTest.java
@@ -0,0 +1,46 @@
+package com.edgareldy.springjdbctutorial.core.catalog.mapper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto;
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Category;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests CategoryMapper (entity to dto and back) on hand-built objects, no mocks.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+class CategoryMapperTest {
+
+ private final CategoryMapper mapper = new CategoryMapper();
+
+ @Test
+ void _01_ShouldCopyIdAndName_WhenConvertingEntityToDto() {
+ CategoryDto dto = mapper.toDto(new Category(3L, "Books"));
+
+ assertThat(dto.getId()).isEqualTo(3L);
+ assertThat(dto.getCategoryName()).isEqualTo("Books");
+ }
+
+ @Test
+ void _02_ShouldReturnNull_WhenEntityIsNull() {
+ assertThat(mapper.toDto(null)).isNull();
+ }
+
+ @Test
+ void _03_ShouldCopyIdAndName_WhenConvertingDtoToEntity() {
+ Category category = mapper.toEntity(new CategoryDto(3L, "Books"));
+
+ assertThat(category.getId()).isEqualTo(3L);
+ assertThat(category.getCategoryName()).isEqualTo("Books");
+ }
+
+ @Test
+ void _04_ShouldReturnNull_WhenDtoIsNull() {
+ assertThat(mapper.toEntity(null)).isNull();
+ }
+}
diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapperTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapperTest.java
new file mode 100644
index 0000000..000f240
--- /dev/null
+++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapperTest.java
@@ -0,0 +1,37 @@
+package com.edgareldy.springjdbctutorial.core.catalog.mapper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Category;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests CategoryRowMapper against a mocked ResultSet, without any database.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+class CategoryRowMapperTest {
+
+ private final CategoryRowMapper mapper = new CategoryRowMapper();
+
+ // Mockito.mock(ResultSet.class) is a fake row whose getters return what the test stubs, column by
+ // column: the test only checks the column-to-field mapping, with no database and no driver.
+ @Test
+ void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException {
+ ResultSet rs = mock(ResultSet.class);
+ when(rs.getLong("id")).thenReturn(7L);
+ when(rs.getString("category_name")).thenReturn("Books");
+
+ Category category = mapper.mapRow(rs, 0);
+
+ assertThat(category.getId()).isEqualTo(7L);
+ assertThat(category.getCategoryName()).isEqualTo("Books");
+ }
+}
diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapperTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapperTest.java
new file mode 100644
index 0000000..e267159
--- /dev/null
+++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapperTest.java
@@ -0,0 +1,51 @@
+package com.edgareldy.springjdbctutorial.core.catalog.mapper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto;
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Product;
+import java.math.BigDecimal;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests ProductMapper (entity to dto and back) on hand-built objects, no mocks.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+class ProductMapperTest {
+
+ private final ProductMapper mapper = new ProductMapper();
+
+ @Test
+ void _01_ShouldCopyEveryField_WhenConvertingEntityToDto() {
+ ProductDto dto = mapper.toDto(new Product(3L, 2L, "Chess", new BigDecimal("25.99")));
+
+ assertThat(dto.getId()).isEqualTo(3L);
+ assertThat(dto.getCategoryId()).isEqualTo(2L);
+ assertThat(dto.getProductName()).isEqualTo("Chess");
+ assertThat(dto.getUnitPrice()).isEqualTo(new BigDecimal("25.99"));
+ }
+
+ @Test
+ void _02_ShouldReturnNull_WhenEntityIsNull() {
+ assertThat(mapper.toDto(null)).isNull();
+ }
+
+ @Test
+ void _03_ShouldCopyEveryField_WhenConvertingDtoToEntity() {
+ Product product = mapper.toEntity(new ProductDto(3L, 2L, "Chess", new BigDecimal("25.99")));
+
+ assertThat(product.getId()).isEqualTo(3L);
+ assertThat(product.getCategoryId()).isEqualTo(2L);
+ assertThat(product.getProductName()).isEqualTo("Chess");
+ assertThat(product.getUnitPrice()).isEqualTo(new BigDecimal("25.99"));
+ }
+
+ @Test
+ void _04_ShouldReturnNull_WhenDtoIsNull() {
+ assertThat(mapper.toEntity(null)).isNull();
+ }
+}
diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapperTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapperTest.java
new file mode 100644
index 0000000..d48ca45
--- /dev/null
+++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapperTest.java
@@ -0,0 +1,41 @@
+package com.edgareldy.springjdbctutorial.core.catalog.mapper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Product;
+import java.math.BigDecimal;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests ProductRowMapper against a mocked ResultSet, including the BigDecimal price column.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+class ProductRowMapperTest {
+
+ private final ProductRowMapper mapper = new ProductRowMapper();
+
+ @Test
+ void _01_ShouldMapEveryColumnAndKeepThePriceScale_WhenRowIsComplete() throws SQLException {
+ ResultSet rs = mock(ResultSet.class);
+ when(rs.getLong("id")).thenReturn(4L);
+ when(rs.getLong("category_id")).thenReturn(2L);
+ when(rs.getString("product_name")).thenReturn("Chess");
+ when(rs.getBigDecimal("unit_price")).thenReturn(new BigDecimal("12.50"));
+
+ Product product = mapper.mapRow(rs, 0);
+
+ assertThat(product.getId()).isEqualTo(4L);
+ assertThat(product.getCategoryId()).isEqualTo(2L);
+ assertThat(product.getProductName()).isEqualTo("Chess");
+ assertThat(product.getUnitPrice()).isEqualTo(new BigDecimal("12.50"));
+ assertThat(product.getUnitPrice().scale()).isEqualTo(2);
+ }
+}
diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImplTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImplTest.java
new file mode 100644
index 0000000..09cf379
--- /dev/null
+++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImplTest.java
@@ -0,0 +1,293 @@
+package com.edgareldy.springjdbctutorial.core.catalog.service.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao;
+import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao;
+import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto;
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Category;
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException;
+import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.dao.DuplicateKeyException;
+
+/**
+ * Unit tests of CategoryServiceImpl: both DAOs are Mockito mocks, assertions are made on the dto objects.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+// MockitoExtension builds the @Mock fields before each test and fails a test whose stubbing is never used
+// (strict stubs), so no test keeps a stub it does not need. No Spring context and no database here.
+@ExtendWith(MockitoExtension.class)
+class CategoryServiceImplTest {
+
+ @Mock
+ private CategoryDao categoryDao;
+ @Mock
+ private ProductDao productDao;
+
+ private CategoryServiceImpl service() {
+ return new CategoryServiceImpl(categoryDao, productDao);
+ }
+
+ private static CategoryDto input(String name) {
+ return new CategoryDto(null, name);
+ }
+
+ // ---------------------------------------------------------------- list
+
+ @Test
+ void _01_ShouldReturnPageOfDtos_WhenListing() {
+ when(categoryDao.findPage(1, 2)).thenReturn(List.of(new Category(3L, "Toys")));
+ when(categoryDao.countAll()).thenReturn(3L);
+
+ PageDto page = service().list(1, 2);
+
+ assertThat(page.getContent()).containsExactly(new CategoryDto(3L, "Toys"));
+ assertThat(page.getPage()).isEqualTo(1);
+ assertThat(page.getSize()).isEqualTo(2);
+ assertThat(page.getTotalElements()).isEqualTo(3L);
+ }
+
+ @Test
+ void _02_ShouldReturnEmptyContent_WhenPageIsBeyondTheLastOne() {
+ when(categoryDao.findPage(50, 10)).thenReturn(List.of());
+ when(categoryDao.countAll()).thenReturn(3L);
+
+ PageDto page = service().list(50, 10);
+
+ assertThat(page.getContent()).isEmpty();
+ assertThat(page.getTotalElements()).isEqualTo(3L);
+ }
+
+ @Test
+ void _03_ShouldThrowBusinessRuleException_WhenPageIsNegative() {
+ assertThatThrownBy(() -> service().list(-1, 10)).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(categoryDao);
+ }
+
+ @Test
+ void _04_ShouldThrowBusinessRuleException_WhenSizeIsZero() {
+ assertThatThrownBy(() -> service().list(0, 0)).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(categoryDao);
+ }
+
+ @Test
+ void _05_ShouldThrowBusinessRuleException_WhenSizeIsAbove100() {
+ assertThatThrownBy(() -> service().list(0, 101)).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(categoryDao);
+ }
+
+ @Test
+ void _06_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() {
+ when(categoryDao.findPage(0, 1)).thenReturn(List.of());
+ when(categoryDao.findPage(0, 100)).thenReturn(List.of());
+
+ assertThat(service().list(0, 1).getSize()).isEqualTo(1);
+ assertThat(service().list(0, 100).getSize()).isEqualTo(100);
+ }
+
+ // ---------------------------------------------------------------- get
+
+ @Test
+ void _07_ShouldReturnDto_WhenCategoryExists() {
+ when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games")));
+
+ assertThat(service().get(2L)).isEqualTo(new CategoryDto(2L, "Games"));
+ }
+
+ @Test
+ void _08_ShouldThrowResourceNotFoundException_WhenCategoryDoesNotExist() {
+ when(categoryDao.findById(9L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service().get(9L)).isInstanceOf(ResourceNotFoundException.class);
+ }
+
+ // ---------------------------------------------------------------- create
+
+ @Test
+ void _09_ShouldInsertTrimmedNameAndReturnDto_WhenNameIsNew() {
+ when(categoryDao.existsByCategoryName("Books")).thenReturn(false);
+ when(categoryDao.insert(any(Category.class))).thenAnswer(invocation -> {
+ Category created = invocation.getArgument(0);
+ created.setId(11L);
+ return created;
+ });
+
+ CategoryDto created = service().create(input(" Books "));
+
+ assertThat(created).isEqualTo(new CategoryDto(11L, "Books"));
+ ArgumentCaptor saved = ArgumentCaptor.forClass(Category.class);
+ verify(categoryDao).insert(saved.capture());
+ assertThat(saved.getValue().getCategoryName()).isEqualTo("Books");
+ }
+
+ @Test
+ void _10_ShouldThrowBusinessRuleException_WhenNameIsBlank() {
+ assertThatThrownBy(() -> service().create(input(" "))).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(categoryDao);
+ }
+
+ @Test
+ void _11_ShouldThrowBusinessRuleException_WhenNameIsNull() {
+ assertThatThrownBy(() -> service().create(input(null))).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(categoryDao);
+ }
+
+ @Test
+ void _12_ShouldThrowBusinessRuleException_WhenNameIsLongerThan100Characters() {
+ assertThatThrownBy(() -> service().create(input("x".repeat(101)))).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(categoryDao);
+ }
+
+ @Test
+ void _13_ShouldAcceptName_WhenExactly100Characters() {
+ String name = "x".repeat(100);
+ when(categoryDao.existsByCategoryName(name)).thenReturn(false);
+ when(categoryDao.insert(any(Category.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ assertThat(service().create(input(name)).getCategoryName()).isEqualTo(name);
+ }
+
+ @Test
+ void _14_ShouldThrowBusinessRuleExceptionWithoutInserting_WhenNameAlreadyExists() {
+ when(categoryDao.existsByCategoryName("Books")).thenReturn(true);
+
+ assertThatThrownBy(() -> service().create(input("Books")))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("Category name already exists");
+ verify(categoryDao, never()).insert(any());
+ }
+
+ @Test
+ void _15_ShouldTurnDuplicateKeyExceptionIntoBusinessRuleException_WhenAConcurrentInsertWonTheRace() {
+ DuplicateKeyException race = new DuplicateKeyException("uq_categories_category_name");
+ when(categoryDao.existsByCategoryName("Books")).thenReturn(false);
+ when(categoryDao.insert(any(Category.class))).thenThrow(race);
+
+ assertThatThrownBy(() -> service().create(input("Books")))
+ .isInstanceOf(BusinessRuleException.class).hasCause(race);
+ }
+
+ // ---------------------------------------------------------------- update
+
+ @Test
+ void _16_ShouldRenameAndReturnDto_WhenNameIsFree() {
+ when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games")));
+ when(categoryDao.existsByCategoryName("Video games")).thenReturn(false);
+
+ CategoryDto updated = service().update(2L, input(" Video games "));
+
+ assertThat(updated).isEqualTo(new CategoryDto(2L, "Video games"));
+ ArgumentCaptor saved = ArgumentCaptor.forClass(Category.class);
+ verify(categoryDao).update(saved.capture());
+ assertThat(saved.getValue().getCategoryName()).isEqualTo("Video games");
+ }
+
+ @Test
+ void _17_ShouldThrowResourceNotFoundException_WhenUpdatingAnUnknownCategory() {
+ when(categoryDao.findById(9L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service().update(9L, input("Books"))).isInstanceOf(ResourceNotFoundException.class);
+ verify(categoryDao, never()).update(any());
+ }
+
+ @Test
+ void _18_ShouldAllowIt_WhenTheNameIsUnchanged() {
+ when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games")));
+
+ CategoryDto updated = service().update(2L, input("Games"));
+
+ assertThat(updated.getCategoryName()).isEqualTo("Games");
+ // The name is the category's own: no uniqueness lookup, so it never collides with itself
+ verify(categoryDao, never()).existsByCategoryName(any());
+ verify(categoryDao).update(any(Category.class));
+ }
+
+ @Test
+ void _19_ShouldThrowBusinessRuleException_WhenRenamingToAnotherCategoryName() {
+ when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games")));
+ when(categoryDao.existsByCategoryName("Books")).thenReturn(true);
+
+ assertThatThrownBy(() -> service().update(2L, input("Books")))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("Category name already exists");
+ verify(categoryDao, never()).update(any());
+ }
+
+ @Test
+ void _20_ShouldThrowBusinessRuleException_WhenUpdatedNameIsBlank() {
+ when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games")));
+
+ assertThatThrownBy(() -> service().update(2L, input(" "))).isInstanceOf(BusinessRuleException.class);
+ verify(categoryDao, never()).update(any());
+ }
+
+ @Test
+ void _21_ShouldTurnDuplicateKeyExceptionIntoBusinessRuleException_WhenRenameLosesTheRace() {
+ DuplicateKeyException race = new DuplicateKeyException("uq_categories_category_name");
+ when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games")));
+ when(categoryDao.existsByCategoryName("Books")).thenReturn(false);
+ doThrow(race).when(categoryDao).update(any(Category.class));
+
+ assertThatThrownBy(() -> service().update(2L, input("Books")))
+ .isInstanceOf(BusinessRuleException.class).hasCause(race);
+ }
+
+ // ---------------------------------------------------------------- delete
+
+ @Test
+ void _22_ShouldDeleteCategory_WhenNoProductReferencesIt() {
+ when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games")));
+ when(productDao.countByCategoryId(2L)).thenReturn(0L);
+
+ service().delete(2L);
+
+ verify(categoryDao).delete(2L);
+ }
+
+ @Test
+ void _23_ShouldThrowResourceNotFoundException_WhenDeletingAnUnknownCategory() {
+ when(categoryDao.findById(9L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service().delete(9L)).isInstanceOf(ResourceNotFoundException.class);
+ verify(categoryDao, never()).delete(any());
+ }
+
+ @Test
+ void _24_ShouldThrowBusinessRuleExceptionWithoutDeleting_WhenProductsStillReferenceTheCategory() {
+ when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games")));
+ when(productDao.countByCategoryId(2L)).thenReturn(2L);
+
+ assertThatThrownBy(() -> service().delete(2L))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("Category still has products");
+ verify(categoryDao, never()).delete(any());
+ }
+
+ @Test
+ void _25_ShouldTurnDataIntegrityViolationIntoBusinessRuleException_WhenAProductAppearsBetweenCheckAndDelete() {
+ DataIntegrityViolationException race = new DataIntegrityViolationException("fk_products_category");
+ when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games")));
+ when(productDao.countByCategoryId(2L)).thenReturn(0L);
+ doThrow(race).when(categoryDao).delete(2L);
+
+ assertThatThrownBy(() -> service().delete(2L))
+ .isInstanceOf(BusinessRuleException.class).hasCause(race);
+ }
+}
diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java
new file mode 100644
index 0000000..f21d7f2
--- /dev/null
+++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java
@@ -0,0 +1,345 @@
+package com.edgareldy.springjdbctutorial.core.catalog.service.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao;
+import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao;
+import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto;
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Category;
+import com.edgareldy.springjdbctutorial.core.catalog.entity.Product;
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException;
+import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DataIntegrityViolationException;
+
+/**
+ * Unit tests of ProductServiceImpl: both DAOs are Mockito mocks, assertions are made on the dto objects.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+@ExtendWith(MockitoExtension.class)
+class ProductServiceImplTest {
+
+ @Mock
+ private ProductDao productDao;
+ @Mock
+ private CategoryDao categoryDao;
+
+ private ProductServiceImpl service() {
+ return new ProductServiceImpl(productDao, categoryDao);
+ }
+
+ private static ProductDto input(Long categoryId, String name, String price) {
+ return new ProductDto(null, categoryId, name, price == null ? null : new BigDecimal(price));
+ }
+
+ private void categoryExists(long id) {
+ when(categoryDao.findById(id)).thenReturn(Optional.of(new Category(id, "Games")));
+ }
+
+ private void insertAssignsId(long id) {
+ when(productDao.insert(any(Product.class))).thenAnswer(invocation -> {
+ Product created = invocation.getArgument(0);
+ created.setId(id);
+ return created;
+ });
+ }
+
+ // ---------------------------------------------------------------- list
+
+ @Test
+ void _01_ShouldReturnPageOfDtosAndPassTheFilter_WhenListingByCategory() {
+ when(productDao.findPage(0, 10, 2L))
+ .thenReturn(List.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99"))));
+ when(productDao.count(2L)).thenReturn(1L);
+
+ PageDto page = service().list(0, 10, 2L);
+
+ assertThat(page.getContent()).containsExactly(new ProductDto(3L, 2L, "Chess", new BigDecimal("25.99")));
+ assertThat(page.getTotalElements()).isEqualTo(1L);
+ }
+
+ @Test
+ void _02_ShouldPassNullFilter_WhenNoCategoryIsGiven() {
+ when(productDao.findPage(0, 20, null)).thenReturn(List.of());
+ when(productDao.count(null)).thenReturn(4L);
+
+ PageDto page = service().list(0, 20, null);
+
+ assertThat(page.getContent()).isEmpty();
+ assertThat(page.getTotalElements()).isEqualTo(4L);
+ }
+
+ @Test
+ void _03_ShouldThrowBusinessRuleException_WhenPageIsNegative() {
+ assertThatThrownBy(() -> service().list(-1, 10, null)).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(productDao);
+ }
+
+ @Test
+ void _04_ShouldThrowBusinessRuleException_WhenSizeIsZero() {
+ assertThatThrownBy(() -> service().list(0, 0, null)).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(productDao);
+ }
+
+ @Test
+ void _05_ShouldThrowBusinessRuleException_WhenSizeIsAbove100() {
+ assertThatThrownBy(() -> service().list(0, 101, null)).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(productDao);
+ }
+
+ @Test
+ void _06_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() {
+ when(productDao.findPage(0, 1, null)).thenReturn(List.of());
+ when(productDao.findPage(0, 100, null)).thenReturn(List.of());
+
+ assertThat(service().list(0, 1, null).getSize()).isEqualTo(1);
+ assertThat(service().list(0, 100, null).getSize()).isEqualTo(100);
+ }
+
+ // ---------------------------------------------------------------- get
+
+ @Test
+ void _07_ShouldReturnDto_WhenProductExists() {
+ when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99"))));
+
+ assertThat(service().get(3L)).isEqualTo(new ProductDto(3L, 2L, "Chess", new BigDecimal("25.99")));
+ }
+
+ @Test
+ void _08_ShouldThrowResourceNotFoundException_WhenProductDoesNotExist() {
+ when(productDao.findById(9L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service().get(9L)).isInstanceOf(ResourceNotFoundException.class);
+ }
+
+ // ---------------------------------------------------------------- create
+
+ @Test
+ void _09_ShouldInsertTrimmedNameAndReturnDto_WhenInputIsValid() {
+ categoryExists(2L);
+ insertAssignsId(11L);
+
+ ProductDto created = service().create(input(2L, " Chess ", "25.99"));
+
+ assertThat(created).isEqualTo(new ProductDto(11L, 2L, "Chess", new BigDecimal("25.99")));
+ ArgumentCaptor saved = ArgumentCaptor.forClass(Product.class);
+ verify(productDao).insert(saved.capture());
+ assertThat(saved.getValue().getProductName()).isEqualTo("Chess");
+ assertThat(saved.getValue().getCategoryId()).isEqualTo(2L);
+ }
+
+ @Test
+ void _10_ShouldThrowResourceNotFoundException_WhenCategoryDoesNotExist() {
+ when(categoryDao.findById(999L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service().create(input(999L, "Chess", "25.99")))
+ .isInstanceOf(ResourceNotFoundException.class);
+ verify(productDao, never()).insert(any());
+ }
+
+ @Test
+ void _11_ShouldThrowResourceNotFoundException_WhenCategoryIdIsNull() {
+ assertThatThrownBy(() -> service().create(input(null, "Chess", "25.99")))
+ .isInstanceOf(ResourceNotFoundException.class);
+ verify(productDao, never()).insert(any());
+ }
+
+ @Test
+ void _12_ShouldThrowBusinessRuleException_WhenNameIsBlank() {
+ assertThatThrownBy(() -> service().create(input(2L, " ", "25.99"))).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(productDao);
+ }
+
+ @Test
+ void _13_ShouldThrowBusinessRuleException_WhenNameIsLongerThan150Characters() {
+ assertThatThrownBy(() -> service().create(input(2L, "x".repeat(151), "25.99")))
+ .isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(productDao);
+ }
+
+ @Test
+ void _14_ShouldAcceptName_WhenExactly150Characters() {
+ categoryExists(2L);
+ insertAssignsId(11L);
+
+ assertThat(service().create(input(2L, "x".repeat(150), "1.00")).getProductName()).hasSize(150);
+ }
+
+ @Test
+ void _15_ShouldThrowBusinessRuleException_WhenPriceIsNull() {
+ assertThatThrownBy(() -> service().create(input(2L, "Chess", null))).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(productDao);
+ }
+
+ @Test
+ void _16_ShouldThrowBusinessRuleException_WhenPriceIsNegative() {
+ assertThatThrownBy(() -> service().create(input(2L, "Chess", "-0.01")))
+ .isInstanceOf(BusinessRuleException.class).hasMessageContaining("negative");
+ verifyNoInteractions(productDao);
+ }
+
+ @Test
+ void _17_ShouldThrowBusinessRuleException_WhenPriceHasMoreThanTwoDecimals() {
+ assertThatThrownBy(() -> service().create(input(2L, "Chess", "1.005")))
+ .isInstanceOf(BusinessRuleException.class).hasMessageContaining("2 decimals");
+ verifyNoInteractions(productDao);
+ }
+
+ @Test
+ void _18_ShouldNormaliseScaleToTwo_WhenPriceHasFewerDecimals() {
+ categoryExists(2L);
+ insertAssignsId(11L);
+
+ ProductDto created = service().create(input(2L, "Chess", "12.5"));
+
+ assertThat(created.getUnitPrice()).isEqualTo(new BigDecimal("12.50"));
+ assertThat(created.getUnitPrice().scale()).isEqualTo(2);
+ }
+
+ @Test
+ void _19_ShouldAcceptPrice_WhenExtraDecimalsAreOnlyTrailingZeros() {
+ categoryExists(2L);
+ insertAssignsId(11L);
+
+ ProductDto created = service().create(input(2L, "Chess", "1.500"));
+
+ assertThat(created.getUnitPrice()).isEqualTo(new BigDecimal("1.50"));
+ }
+
+ @Test
+ void _20_ShouldAcceptZeroAndMaximumPrices_WhenOnTheBoundaries() {
+ categoryExists(2L);
+ insertAssignsId(11L);
+
+ assertThat(service().create(input(2L, "Free", "0")).getUnitPrice()).isEqualTo(new BigDecimal("0.00"));
+ assertThat(service().create(input(2L, "Gold", "9999999999.99")).getUnitPrice())
+ .isEqualTo(new BigDecimal("9999999999.99"));
+ }
+
+ @Test
+ void _21_ShouldThrowBusinessRuleException_WhenPriceExceedsTheColumnCapacity() {
+ assertThatThrownBy(() -> service().create(input(2L, "Chess", "10000000000.00")))
+ .isInstanceOf(BusinessRuleException.class).hasMessageContaining("exceed");
+ verifyNoInteractions(productDao);
+ }
+
+ // ---------------------------------------------------------------- update
+
+ @Test
+ void _22_ShouldUpdateEveryFieldAndReturnDto_WhenInputIsValid() {
+ when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99"))));
+ categoryExists(1L);
+
+ ProductDto updated = service().update(3L, input(1L, " Atlas ", "30"));
+
+ assertThat(updated).isEqualTo(new ProductDto(3L, 1L, "Atlas", new BigDecimal("30.00")));
+ ArgumentCaptor saved = ArgumentCaptor.forClass(Product.class);
+ verify(productDao).update(saved.capture());
+ assertThat(saved.getValue().getCategoryId()).isEqualTo(1L);
+ assertThat(saved.getValue().getProductName()).isEqualTo("Atlas");
+ }
+
+ @Test
+ void _23_ShouldThrowResourceNotFoundException_WhenUpdatingAnUnknownProduct() {
+ when(productDao.findById(9L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service().update(9L, input(2L, "Chess", "1.00")))
+ .isInstanceOf(ResourceNotFoundException.class);
+ verify(productDao, never()).update(any());
+ }
+
+ @Test
+ void _24_ShouldThrowResourceNotFoundException_WhenUpdatingToAnUnknownCategory() {
+ when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99"))));
+ when(categoryDao.findById(999L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service().update(3L, input(999L, "Chess", "1.00")))
+ .isInstanceOf(ResourceNotFoundException.class);
+ verify(productDao, never()).update(any());
+ }
+
+ @Test
+ void _25_ShouldThrowBusinessRuleException_WhenUpdatedPriceIsInvalid() {
+ when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99"))));
+
+ assertThatThrownBy(() -> service().update(3L, input(2L, "Chess", "-1")))
+ .isInstanceOf(BusinessRuleException.class);
+ verify(productDao, never()).update(any());
+ }
+
+ // ---------------------------------------------------------------- delete
+
+ @Test
+ void _26_ShouldDeleteProduct_WhenItExists() {
+ when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99"))));
+
+ service().delete(3L);
+
+ verify(productDao).delete(3L);
+ }
+
+ @Test
+ void _27_ShouldThrowResourceNotFoundException_WhenDeletingAnUnknownProduct() {
+ when(productDao.findById(9L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service().delete(9L)).isInstanceOf(ResourceNotFoundException.class);
+ verify(productDao, never()).delete(any());
+ }
+
+ @Test
+ void _28_ShouldTurnDataIntegrityViolationIntoBusinessRuleException_WhenAnOrderReferencesTheProduct() {
+ DataIntegrityViolationException fk = new DataIntegrityViolationException("fk_orders_product");
+ when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99"))));
+ doThrow(fk).when(productDao).delete(3L);
+
+ assertThatThrownBy(() -> service().delete(3L))
+ .isInstanceOf(BusinessRuleException.class).hasCause(fk);
+ }
+
+ // Non-regression: the category can vanish between the existence check and the write
+ @Test
+ void _29_ShouldThrowResourceNotFoundException_WhenTheCategoryIsDeletedBeforeTheInsert() {
+ categoryExists(2L);
+ when(productDao.insert(any(Product.class))).thenThrow(new DataIntegrityViolationException("fk_products_category"));
+
+ assertThatThrownBy(() -> service().create(input(2L, "Chess", "25.99")))
+ .isInstanceOf(ResourceNotFoundException.class).hasMessage("Category not found: 2");
+ }
+
+ @Test
+ void _30_ShouldThrowResourceNotFoundException_WhenTheCategoryIsDeletedBeforeTheUpdate() {
+ categoryExists(2L);
+ when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99"))));
+ doThrow(new DataIntegrityViolationException("fk_products_category")).when(productDao).update(any(Product.class));
+
+ assertThatThrownBy(() -> service().update(3L, input(2L, "Chess", "26.00")))
+ .isInstanceOf(ResourceNotFoundException.class).hasMessage("Category not found: 2");
+ }
+
+ // Non-regression: a huge exponent must be refused by the bound before any setScale expansion
+ @Test
+ void _31_ShouldThrowBusinessRuleException_WhenThePriceHasAHugeExponent() {
+ assertThatThrownBy(() -> service().create(input(2L, "Chess", "1E+999999999")))
+ .isInstanceOf(BusinessRuleException.class).hasMessageContaining("must not exceed");
+ verify(productDao, never()).insert(any());
+ }
+}
diff --git a/core/catalog/src/test/resources/category-dao-dataset.sql b/core/catalog/src/test/resources/category-dao-dataset.sql
new file mode 100644
index 0000000..d668268
--- /dev/null
+++ b/core/catalog/src/test/resources/category-dao-dataset.sql
@@ -0,0 +1,11 @@
+-- Fixture of CategoryDaoImplTest only. Wipes the catalog tables (products first, by cascade), then loads three
+-- categories: Books (1), Games (2) and Toys (3). Category 3 has one product so the foreign key can be exercised.
+TRUNCATE TABLE products, categories RESTART IDENTITY CASCADE;
+
+INSERT INTO categories (id, category_name) VALUES (1, 'Books'), (2, 'Games'), (3, 'Toys');
+
+INSERT INTO products (id, category_id, product_name, unit_price) VALUES (1, 3, 'Robot', 19.90);
+
+-- Explicit ids do not advance the sequences: move them high so generated ids never collide
+ALTER SEQUENCE categories_id_seq RESTART WITH 100;
+ALTER SEQUENCE products_id_seq RESTART WITH 100;
diff --git a/core/catalog/src/test/resources/product-dao-dataset.sql b/core/catalog/src/test/resources/product-dao-dataset.sql
new file mode 100644
index 0000000..0852879
--- /dev/null
+++ b/core/catalog/src/test/resources/product-dao-dataset.sql
@@ -0,0 +1,16 @@
+-- Fixture of ProductDaoImplTest only. Wipes the catalog tables, then loads three categories (Books 1, Games 2 and
+-- Empty 3 which holds no product) and four products: Novel (1) and Atlas (2) in Books, Chess (3) and Cards (4)
+-- in Games. Ids are ordered so pagination is predictable.
+TRUNCATE TABLE products, categories RESTART IDENTITY CASCADE;
+
+INSERT INTO categories (id, category_name) VALUES (1, 'Books'), (2, 'Games'), (3, 'Empty');
+
+INSERT INTO products (id, category_id, product_name, unit_price) VALUES
+ (1, 1, 'Novel', 12.50),
+ (2, 1, 'Atlas', 30.00),
+ (3, 2, 'Chess', 25.99),
+ (4, 2, 'Cards', 0.00);
+
+-- Explicit ids do not advance the sequences: move them high so generated ids never collide
+ALTER SEQUENCE categories_id_seq RESTART WITH 100;
+ALTER SEQUENCE products_id_seq RESTART WITH 100;
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java
index 295e659..e1185f7 100644
--- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java
@@ -37,7 +37,8 @@
// @Bean methods, so nothing gets wired by accident.
@Configuration
@EnableWebMvc
-@Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class, SecurityConfig.class})
+@Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class,
+ com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class, SecurityConfig.class})
@ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"})
public class WebMvcConfig implements WebMvcConfigurer {
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CategoryController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CategoryController.java
new file mode 100644
index 0000000..6678c55
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CategoryController.java
@@ -0,0 +1,77 @@
+package com.edgareldy.springjdbctutorial.ws.controller;
+
+import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService;
+import com.edgareldy.springjdbctutorial.ws.converter.CategoryConverter;
+import com.edgareldy.springjdbctutorial.ws.payload.catalog.CategoryRequest;
+import com.edgareldy.springjdbctutorial.ws.payload.catalog.CategoryResponse;
+import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse;
+import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * HTTP entry points to manage categories. No business logic.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+@RestController
+@RequestMapping("/api/v1/categories")
+public class CategoryController {
+
+ private final CategoryService categoryService;
+
+ public CategoryController(CategoryService categoryService) {
+ this.categoryService = categoryService;
+ }
+
+ @GetMapping
+ @PreAuthorize("hasPermission('CATEGORY','READ')")
+ public ApiResponse> list(@RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return ApiResponse.success(CategoryConverter.toPageResponse(categoryService.list(page, size)), "Categories");
+ }
+
+ @GetMapping("/{id}")
+ @PreAuthorize("hasPermission('CATEGORY','READ')")
+ public ApiResponse get(@PathVariable Long id) {
+ return ApiResponse.success(CategoryConverter.toResponse(categoryService.get(id)), "Category");
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ @PreAuthorize("hasPermission('CATEGORY','WRITE')")
+ public ApiResponse create(@Valid @RequestBody CategoryRequest request) {
+ return ApiResponse.success(CategoryConverter.toResponse(categoryService.create(CategoryConverter.toDto(request))),
+ "Category created");
+ }
+
+ @PutMapping("/{id}")
+ @PreAuthorize("hasPermission('CATEGORY','WRITE')")
+ public ApiResponse update(@PathVariable Long id, @Valid @RequestBody CategoryRequest request) {
+ return ApiResponse.success(CategoryConverter.toResponse(categoryService.update(id, CategoryConverter.toDto(request))),
+ "Category updated");
+ }
+
+ @DeleteMapping("/{id}")
+ @PreAuthorize("hasPermission('CATEGORY','WRITE')")
+ public ApiResponse delete(@PathVariable Long id) {
+ categoryService.delete(id);
+ return ApiResponse.success(null, "Category deleted");
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/ProductController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/ProductController.java
new file mode 100644
index 0000000..ced30db
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/ProductController.java
@@ -0,0 +1,78 @@
+package com.edgareldy.springjdbctutorial.ws.controller;
+
+import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService;
+import com.edgareldy.springjdbctutorial.ws.converter.ProductConverter;
+import com.edgareldy.springjdbctutorial.ws.payload.catalog.ProductRequest;
+import com.edgareldy.springjdbctutorial.ws.payload.catalog.ProductResponse;
+import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse;
+import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * HTTP entry points to manage products. No business logic.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+@RestController
+@RequestMapping("/api/v1/products")
+public class ProductController {
+
+ private final ProductService productService;
+
+ public ProductController(ProductService productService) {
+ this.productService = productService;
+ }
+
+ @GetMapping
+ @PreAuthorize("hasPermission('PRODUCT','READ')")
+ public ApiResponse> list(@RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
+ @RequestParam(required = false) Long categoryId) {
+ return ApiResponse.success(ProductConverter.toPageResponse(productService.list(page, size, categoryId)), "Products");
+ }
+
+ @GetMapping("/{id}")
+ @PreAuthorize("hasPermission('PRODUCT','READ')")
+ public ApiResponse get(@PathVariable Long id) {
+ return ApiResponse.success(ProductConverter.toResponse(productService.get(id)), "Product");
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ @PreAuthorize("hasPermission('PRODUCT','WRITE')")
+ public ApiResponse create(@Valid @RequestBody ProductRequest request) {
+ return ApiResponse.success(ProductConverter.toResponse(productService.create(ProductConverter.toDto(request))),
+ "Product created");
+ }
+
+ @PutMapping("/{id}")
+ @PreAuthorize("hasPermission('PRODUCT','WRITE')")
+ public ApiResponse update(@PathVariable Long id, @Valid @RequestBody ProductRequest request) {
+ return ApiResponse.success(ProductConverter.toResponse(productService.update(id, ProductConverter.toDto(request))),
+ "Product updated");
+ }
+
+ @DeleteMapping("/{id}")
+ @PreAuthorize("hasPermission('PRODUCT','WRITE')")
+ public ApiResponse delete(@PathVariable Long id) {
+ productService.delete(id);
+ return ApiResponse.success(null, "Product deleted");
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CategoryConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CategoryConverter.java
new file mode 100644
index 0000000..34bed91
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CategoryConverter.java
@@ -0,0 +1,42 @@
+package com.edgareldy.springjdbctutorial.ws.converter;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto;
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.ws.payload.catalog.CategoryRequest;
+import com.edgareldy.springjdbctutorial.ws.payload.catalog.CategoryResponse;
+import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Converts between the core CategoryDto and the category HTTP payloads.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public final class CategoryConverter {
+
+ private CategoryConverter() {
+ }
+
+ public static CategoryDto toDto(CategoryRequest request) {
+ CategoryDto dto = new CategoryDto();
+ dto.setCategoryName(request.getCategoryName());
+ return dto;
+ }
+
+ public static CategoryResponse toResponse(CategoryDto dto) {
+ return new CategoryResponse(dto.getId(), dto.getCategoryName());
+ }
+
+ public static PageResponse toPageResponse(PageDto page) {
+ List content = new ArrayList<>();
+ for (CategoryDto dto : page.getContent()) {
+ content.add(toResponse(dto));
+ }
+ return PageResponse.of(content, page.getPage(), page.getSize(), page.getTotalElements());
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/ProductConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/ProductConverter.java
new file mode 100644
index 0000000..906f204
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/ProductConverter.java
@@ -0,0 +1,44 @@
+package com.edgareldy.springjdbctutorial.ws.converter;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto;
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.ws.payload.catalog.ProductRequest;
+import com.edgareldy.springjdbctutorial.ws.payload.catalog.ProductResponse;
+import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Converts between the core ProductDto and the product HTTP payloads.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public final class ProductConverter {
+
+ private ProductConverter() {
+ }
+
+ public static ProductDto toDto(ProductRequest request) {
+ ProductDto dto = new ProductDto();
+ dto.setCategoryId(request.getCategoryId());
+ dto.setProductName(request.getProductName());
+ dto.setUnitPrice(request.getUnitPrice());
+ return dto;
+ }
+
+ public static ProductResponse toResponse(ProductDto dto) {
+ return new ProductResponse(dto.getId(), dto.getCategoryId(), dto.getProductName(), dto.getUnitPrice());
+ }
+
+ public static PageResponse toPageResponse(PageDto page) {
+ List content = new ArrayList<>();
+ for (ProductDto dto : page.getContent()) {
+ content.add(toResponse(dto));
+ }
+ return PageResponse.of(content, page.getPage(), page.getSize(), page.getTotalElements());
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryRequest.java
new file mode 100644
index 0000000..cf4ceff
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryRequest.java
@@ -0,0 +1,56 @@
+package com.edgareldy.springjdbctutorial.ws.payload.catalog;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+import java.util.Objects;
+
+/**
+ * Body of the create and update category calls.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class CategoryRequest {
+
+ @NotBlank
+ @Size(max = 100)
+ private String categoryName;
+
+ public CategoryRequest() {
+ }
+
+ public String getCategoryName() {
+ return categoryName;
+ }
+
+ public void setCategoryName(String categoryName) {
+ this.categoryName = categoryName;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ CategoryRequest that = (CategoryRequest) o;
+ return Objects.equals(categoryName, that.categoryName);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(categoryName);
+ }
+
+ @Override
+ public String toString() {
+ return "CategoryRequest{" +
+ "categoryName=" + categoryName +
+ "}";
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryResponse.java
new file mode 100644
index 0000000..8888024
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryResponse.java
@@ -0,0 +1,67 @@
+package com.edgareldy.springjdbctutorial.ws.payload.catalog;
+
+import java.util.Objects;
+
+/**
+ * A category as returned by the API.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class CategoryResponse {
+
+ private Long id;
+
+ private String categoryName;
+
+ public CategoryResponse() {
+ }
+
+ public CategoryResponse(Long id, String categoryName) {
+ this.id = id;
+ this.categoryName = categoryName;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getCategoryName() {
+ return categoryName;
+ }
+
+ public void setCategoryName(String categoryName) {
+ this.categoryName = categoryName;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ CategoryResponse that = (CategoryResponse) o;
+ return Objects.equals(id, that.id) && Objects.equals(categoryName, that.categoryName);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, categoryName);
+ }
+
+ @Override
+ public String toString() {
+ return "CategoryResponse{" +
+ "id=" + id +
+ ", categoryName=" + categoryName +
+ "}";
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductRequest.java
new file mode 100644
index 0000000..a5cf851
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductRequest.java
@@ -0,0 +1,87 @@
+package com.edgareldy.springjdbctutorial.ws.payload.catalog;
+
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.Digits;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+
+import java.math.BigDecimal;
+import java.util.Objects;
+
+/**
+ * Body of the create and update product calls.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ProductRequest {
+
+ @NotNull
+ private Long categoryId;
+
+ @NotBlank
+ @Size(max = 150)
+ private String productName;
+
+ @NotNull
+ @DecimalMin("0.00")
+ @Digits(integer = 10, fraction = 2)
+ private BigDecimal unitPrice;
+
+ public ProductRequest() {
+ }
+
+ public Long getCategoryId() {
+ return categoryId;
+ }
+
+ public void setCategoryId(Long categoryId) {
+ this.categoryId = categoryId;
+ }
+
+ public String getProductName() {
+ return productName;
+ }
+
+ public void setProductName(String productName) {
+ this.productName = productName;
+ }
+
+ public BigDecimal getUnitPrice() {
+ return unitPrice;
+ }
+
+ public void setUnitPrice(BigDecimal unitPrice) {
+ this.unitPrice = unitPrice;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ ProductRequest that = (ProductRequest) o;
+ return Objects.equals(categoryId, that.categoryId) && Objects.equals(productName, that.productName)
+ && Objects.equals(unitPrice, that.unitPrice);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(categoryId, productName, unitPrice);
+ }
+
+ @Override
+ public String toString() {
+ return "ProductRequest{" +
+ "categoryId=" + categoryId +
+ ", productName=" + productName +
+ ", unitPrice=" + unitPrice +
+ "}";
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductResponse.java
new file mode 100644
index 0000000..4773b5e
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductResponse.java
@@ -0,0 +1,92 @@
+package com.edgareldy.springjdbctutorial.ws.payload.catalog;
+
+import java.math.BigDecimal;
+import java.util.Objects;
+
+/**
+ * A product as returned by the API.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ProductResponse {
+
+ private Long id;
+
+ private Long categoryId;
+
+ private String productName;
+
+ private BigDecimal unitPrice;
+
+ public ProductResponse() {
+ }
+
+ public ProductResponse(Long id, Long categoryId, String productName, BigDecimal unitPrice) {
+ this.id = id;
+ this.categoryId = categoryId;
+ this.productName = productName;
+ this.unitPrice = unitPrice;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Long getCategoryId() {
+ return categoryId;
+ }
+
+ public void setCategoryId(Long categoryId) {
+ this.categoryId = categoryId;
+ }
+
+ public String getProductName() {
+ return productName;
+ }
+
+ public void setProductName(String productName) {
+ this.productName = productName;
+ }
+
+ public BigDecimal getUnitPrice() {
+ return unitPrice;
+ }
+
+ public void setUnitPrice(BigDecimal unitPrice) {
+ this.unitPrice = unitPrice;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ ProductResponse that = (ProductResponse) o;
+ return Objects.equals(id, that.id) && Objects.equals(categoryId, that.categoryId) && Objects.equals(productName, that.productName) && Objects.equals(unitPrice, that.unitPrice);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, categoryId, productName, unitPrice);
+ }
+
+ @Override
+ public String toString() {
+ return "ProductResponse{" +
+ "id=" + id +
+ ", categoryId=" + categoryId +
+ ", productName=" + productName +
+ ", unitPrice=" + unitPrice +
+ "}";
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CatalogFlowIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CatalogFlowIntegrationTest.java
new file mode 100644
index 0000000..fb189c8
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CatalogFlowIntegrationTest.java
@@ -0,0 +1,385 @@
+package com.edgareldy.springjdbctutorial.ws.integration;
+
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError;
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
+
+import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer;
+import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig;
+import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport;
+import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport.TestUser;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+
+/**
+ * End-to-end catalog flows through the REAL stack (real WebMvcConfig, real services, real PostgreSQL migrated by
+ * Flyway, real logins): category and product CRUD, the categoryId filter, referenced deletes, unknown categories,
+ * duplicate names, price edge cases, pagination bounds and permission enforcement.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+// Same recipe as RbacFlowIntegrationTest: the whole application context on the shared Testcontainers database.
+// Every category and product a test creates is named it-, and tearDown() removes them (plus the users and
+// roles of RbacTestSupport), so the classes sharing the one database never see each other's rows. The catalog
+// domain has no last-admin style global rule, so no isolation of other holders is needed here.
+@SpringJUnitWebConfig(WebMvcConfig.class)
+class CatalogFlowIntegrationTest {
+
+ private static final String PREFIX = "it-";
+
+ @DynamicPropertySource
+ static void datasource(DynamicPropertyRegistry registry) {
+ PostgresTestContainer.register(registry);
+ }
+
+ @Autowired
+ private WebApplicationContext context;
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ private MockMvc mockMvc;
+ private RbacTestSupport support;
+
+ @BeforeEach
+ void setUp() {
+ mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
+ support = new RbacTestSupport(mockMvc, jdbcTemplate);
+ }
+
+ @AfterEach
+ void tearDown() {
+ // Products first: they reference the categories
+ jdbcTemplate.update("DELETE FROM products WHERE product_name LIKE ?", PREFIX + "%");
+ jdbcTemplate.update("DELETE FROM categories WHERE category_name LIKE ?", PREFIX + "%");
+ support.close();
+ }
+
+ private static String unique() {
+ return PREFIX + UUID.randomUUID().toString().substring(0, 8);
+ }
+
+ private static String categoryBody(String name) {
+ return "{\"categoryName\":\"" + name + "\"}";
+ }
+
+ private static String productBody(long categoryId, String name, String price) {
+ return "{\"categoryId\":" + categoryId + ",\"productName\":\"" + name + "\",\"unitPrice\":" + price + "}";
+ }
+
+ private static long idOf(MvcResult result) throws Exception {
+ return assertSuccess(result, 201).get("data").get("id").asLong();
+ }
+
+ private long createCategory(String jwt, String name) throws Exception {
+ return idOf(support.post(jwt, "/api/v1/categories", categoryBody(name)));
+ }
+
+ private long createProduct(String jwt, long categoryId, String name, String price) throws Exception {
+ return idOf(support.post(jwt, "/api/v1/products", productBody(categoryId, name, price)));
+ }
+
+ private long count(String sql, Object... args) {
+ return jdbcTemplate.queryForObject(sql, Long.class, args);
+ }
+
+ private List productNames(JsonNode page) {
+ List names = new ArrayList<>();
+ for (JsonNode product : page.get("data").get("content")) {
+ names.add(product.get("productName").asText());
+ }
+ return names;
+ }
+
+ // ---------------------------------------------------------------- categories
+
+ @Test
+ void _01_ShouldCreateReadUpdateListAndDeleteACategory_WhenAdminRunsTheFullCrud() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ String name = unique();
+
+ long id = createCategory(jwt, " " + name + " ");
+
+ // The stored name is trimmed
+ JsonNode read = assertSuccess(support.get(jwt, "/api/v1/categories/" + id), 200);
+ assertThat(read.get("data").get("categoryName").asText()).isEqualTo(name);
+
+ String renamed = unique();
+ JsonNode updated = assertSuccess(support.put(jwt, "/api/v1/categories/" + id, categoryBody(renamed)), 200);
+ assertThat(updated.get("data").get("categoryName").asText()).isEqualTo(renamed);
+ assertThat(count("SELECT COUNT(*) FROM categories WHERE category_name = ?", renamed)).isEqualTo(1L);
+
+ JsonNode list = assertSuccess(support.get(jwt, "/api/v1/categories?size=100"), 200);
+ boolean listed = false;
+ for (JsonNode category : list.get("data").get("content")) {
+ listed |= category.get("id").asLong() == id;
+ }
+ assertThat(listed).isTrue();
+
+ JsonNode deleted = assertSuccess(support.delete(jwt, "/api/v1/categories/" + id), 200);
+ assertThat(deleted.get("data") == null || deleted.get("data").isNull()).isTrue();
+ assertError(support.get(jwt, "/api/v1/categories/" + id), 404);
+ }
+
+ @Test
+ void _02_ShouldReturn422_WhenCategoryNameIsDuplicatedOnCreateOrRename() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ String first = unique();
+ long firstId = createCategory(jwt, first);
+ long secondId = createCategory(jwt, unique());
+
+ JsonNode onCreate = assertError(support.post(jwt, "/api/v1/categories", categoryBody(first)), 422);
+ JsonNode onRename = assertError(support.put(jwt, "/api/v1/categories/" + secondId, categoryBody(first)), 422);
+
+ assertThat(onCreate.get("message").asText()).isEqualTo("Category name already exists");
+ assertThat(onRename.get("message").asText()).isEqualTo("Category name already exists");
+ // Saving a category under its own name is not a duplicate
+ assertSuccess(support.put(jwt, "/api/v1/categories/" + firstId, categoryBody(first)), 200);
+ assertThat(count("SELECT COUNT(*) FROM categories WHERE category_name = ?", first)).isEqualTo(1L);
+ }
+
+ @Test
+ void _03_ShouldRefuseTheDeleteWith422ThenSucceed_WhenTheCategoryHasProductsUntilTheyAreDeleted() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long categoryId = createCategory(jwt, unique());
+ long productId = createProduct(jwt, categoryId, unique(), "5.00");
+
+ JsonNode refused = assertError(support.delete(jwt, "/api/v1/categories/" + categoryId), 422);
+
+ assertThat(refused.get("message").asText()).isEqualTo("Category still has products");
+ assertSuccess(support.get(jwt, "/api/v1/categories/" + categoryId), 200);
+
+ assertSuccess(support.delete(jwt, "/api/v1/products/" + productId), 200);
+ assertSuccess(support.delete(jwt, "/api/v1/categories/" + categoryId), 200);
+ assertError(support.get(jwt, "/api/v1/categories/" + categoryId), 404);
+ }
+
+ @Test
+ void _04_ShouldReturn404_WhenCategoryDoesNotExist() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+
+ assertError(support.get(jwt, "/api/v1/categories/999999999"), 404);
+ assertError(support.put(jwt, "/api/v1/categories/999999999", categoryBody(unique())), 404);
+ assertError(support.delete(jwt, "/api/v1/categories/999999999"), 404);
+ }
+
+ // ---------------------------------------------------------------- products
+
+ @Test
+ void _05_ShouldCreateReadUpdateAndDeleteAProductWithExactPrice_WhenAdminRunsTheFullCrud() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long categoryId = createCategory(jwt, unique());
+ long otherCategoryId = createCategory(jwt, unique());
+ String name = unique();
+
+ MvcResult created = support.post(jwt, "/api/v1/products", productBody(categoryId, " " + name + " ", "12.5"));
+ long id = idOf(created);
+
+ // The price comes back with two decimals, and the database holds exactly 12.50
+ assertThat(created.getResponse().getContentAsString()).contains("\"unitPrice\":12.50");
+ assertThat(jdbcTemplate.queryForObject("SELECT unit_price FROM products WHERE id = ?", BigDecimal.class, id))
+ .isEqualTo(new BigDecimal("12.50"));
+ JsonNode read = assertSuccess(support.get(jwt, "/api/v1/products/" + id), 200);
+ assertThat(read.get("data").get("productName").asText()).isEqualTo(name);
+ assertThat(read.get("data").get("categoryId").asLong()).isEqualTo(categoryId);
+
+ String renamed = unique();
+ JsonNode updated = assertSuccess(support.put(jwt, "/api/v1/products/" + id,
+ productBody(otherCategoryId, renamed, "1.5")), 200);
+ assertThat(updated.get("data").get("categoryId").asLong()).isEqualTo(otherCategoryId);
+ assertThat(updated.get("data").get("productName").asText()).isEqualTo(renamed);
+ assertThat(updated.get("data").get("unitPrice").decimalValue()).isEqualByComparingTo("1.50");
+
+ assertSuccess(support.delete(jwt, "/api/v1/products/" + id), 200);
+ assertError(support.get(jwt, "/api/v1/products/" + id), 404);
+ }
+
+ @Test
+ void _06_ShouldReturn404AndStoreNothing_WhenProductTargetsAnUnknownCategory() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long categoryId = createCategory(jwt, unique());
+ String name = unique();
+ long id = createProduct(jwt, categoryId, unique(), "3.00");
+
+ JsonNode onCreate = assertError(support.post(jwt, "/api/v1/products", productBody(999999999L, name, "3.00")), 404);
+ assertError(support.put(jwt, "/api/v1/products/" + id, productBody(999999999L, name, "3.00")), 404);
+
+ assertThat(onCreate.get("message").asText()).contains("Category not found");
+ assertThat(count("SELECT COUNT(*) FROM products WHERE product_name = ?", name)).isZero();
+ // The failed update left the product on its category
+ assertThat(count("SELECT category_id FROM products WHERE id = ?", id)).isEqualTo(categoryId);
+ }
+
+ @Test
+ void _07_ShouldReturn404_WhenProductDoesNotExist() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long categoryId = createCategory(jwt, unique());
+
+ assertError(support.get(jwt, "/api/v1/products/999999999"), 404);
+ assertError(support.put(jwt, "/api/v1/products/999999999", productBody(categoryId, unique(), "1.00")), 404);
+ assertError(support.delete(jwt, "/api/v1/products/999999999"), 404);
+ }
+
+ @Test
+ void _08_ShouldKeepOnlyTheProductsOfTheCategory_WhenFilteringByCategoryId() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long categoryA = createCategory(jwt, unique());
+ long categoryB = createCategory(jwt, unique());
+ long emptyCategory = createCategory(jwt, unique());
+ String a1 = unique();
+ String a2 = unique();
+ String b1 = unique();
+ createProduct(jwt, categoryA, a1, "1.00");
+ createProduct(jwt, categoryA, a2, "2.00");
+ createProduct(jwt, categoryB, b1, "3.00");
+
+ JsonNode filteredA = assertSuccess(support.get(jwt, "/api/v1/products?size=100&categoryId=" + categoryA), 200);
+ JsonNode pagedA = assertSuccess(support.get(jwt, "/api/v1/products?size=1&page=1&categoryId=" + categoryA), 200);
+ JsonNode filteredB = assertSuccess(support.get(jwt, "/api/v1/products?categoryId=" + categoryB), 200);
+ JsonNode filteredEmpty = assertSuccess(support.get(jwt, "/api/v1/products?categoryId=" + emptyCategory), 200);
+
+ assertThat(productNames(filteredA)).containsExactly(a1, a2);
+ assertThat(filteredA.get("data").get("totalElements").asLong()).isEqualTo(2L);
+ assertThat(productNames(pagedA)).containsExactly(a2);
+ assertThat(pagedA.get("data").get("totalElements").asLong()).isEqualTo(2L);
+ assertThat(pagedA.get("data").get("totalPages").asInt()).isEqualTo(2);
+ assertThat(productNames(filteredB)).containsExactly(b1);
+ assertThat(filteredEmpty.get("data").get("content")).isEmpty();
+ assertThat(filteredEmpty.get("data").get("totalElements").asLong()).isZero();
+ }
+
+ @Test
+ void _09_ShouldRejectBadPricesWith400AndAcceptTheBoundaries_WhenPricesAreSentThroughHttp() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long categoryId = createCategory(jwt, unique());
+ String name = unique();
+
+ JsonNode negative = assertError(support.post(jwt, "/api/v1/products", productBody(categoryId, name, "-0.01")), 400);
+ JsonNode threeDecimals = assertError(support.post(jwt, "/api/v1/products", productBody(categoryId, name, "1.005")), 400);
+ JsonNode huge = assertError(support.post(jwt, "/api/v1/products", productBody(categoryId, name, "10000000000.00")), 400);
+ JsonNode missing = assertError(support.post(jwt, "/api/v1/products",
+ "{\"categoryId\":" + categoryId + ",\"productName\":\"" + name + "\"}"), 400);
+
+ assertThat(negative.get("message").asText()).contains("unitPrice: ");
+ assertThat(threeDecimals.get("message").asText()).contains("unitPrice: ");
+ assertThat(huge.get("message").asText()).contains("unitPrice: ");
+ assertThat(missing.get("message").asText()).contains("unitPrice: ");
+ assertThat(count("SELECT COUNT(*) FROM products WHERE product_name = ?", name)).isZero();
+
+ // The boundaries themselves are valid: zero, and the largest NUMERIC(12, 2) value
+ long free = createProduct(jwt, categoryId, unique(), "0");
+ long gold = createProduct(jwt, categoryId, unique(), "9999999999.99");
+ assertThat(jdbcTemplate.queryForObject("SELECT unit_price FROM products WHERE id = ?", BigDecimal.class, free))
+ .isEqualByComparingTo("0.00");
+ assertThat(jdbcTemplate.queryForObject("SELECT unit_price FROM products WHERE id = ?", BigDecimal.class, gold))
+ .isEqualByComparingTo("9999999999.99");
+ }
+
+ // ---------------------------------------------------------------- pagination
+
+ @Test
+ void _10_ShouldReturn400NamingTheParameter_WhenPageOrSizeIsOutOfBounds() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+
+ for (String base : List.of("/api/v1/categories", "/api/v1/products")) {
+ JsonNode page = assertError(support.get(jwt, base + "?page=-1"), 400);
+ JsonNode zero = assertError(support.get(jwt, base + "?size=0"), 400);
+ JsonNode big = assertError(support.get(jwt, base + "?size=101"), 400);
+
+ assertThat(page.get("message").asText()).contains("page: ");
+ assertThat(zero.get("message").asText()).contains("size: ");
+ assertThat(big.get("message").asText()).contains("size: ");
+ assertSuccess(support.get(jwt, base + "?size=1"), 200);
+ assertSuccess(support.get(jwt, base + "?size=100"), 200);
+ }
+ }
+
+ @Test
+ void _11_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long categoryId = createCategory(jwt, unique());
+ createProduct(jwt, categoryId, unique(), "1.00");
+
+ for (String base : List.of("/api/v1/categories", "/api/v1/products")) {
+ JsonNode data = assertSuccess(support.get(jwt, base + "?page=100000&size=10"), 200).get("data");
+
+ assertThat(data.has("content")).isTrue();
+ assertThat(data.get("content").isArray()).isTrue();
+ assertThat(data.get("content")).isEmpty();
+ assertThat(data.get("page").asInt()).isEqualTo(100000);
+ assertThat(data.get("totalElements").asLong()).isGreaterThanOrEqualTo(1L);
+ }
+ }
+
+ // ---------------------------------------------------------------- permissions
+
+ @Test
+ void _12_ShouldReturn401_WhenNoTokenIsSent() throws Exception {
+ for (String url : List.of("/api/v1/categories", "/api/v1/categories/1", "/api/v1/products", "/api/v1/products/1")) {
+ assertError(support.get(null, url), 401);
+ }
+ assertError(support.post(null, "/api/v1/categories", categoryBody(unique())), 401);
+ assertError(support.post(null, "/api/v1/products", productBody(1L, unique(), "1.00")), 401);
+ }
+
+ @Test
+ void _13_ShouldReturn403AndChangeNothing_WhenUserLacksTheWritePermission() throws Exception {
+ TestUser simple = support.createSimpleUser();
+ TestUser readOnly = support.createUser(true, false, support.createRole("CATEGORY:READ", "PRODUCT:READ"));
+ String admin = support.createAdmin().getJwt();
+ long categoryId = createCategory(admin, unique());
+ long productId = createProduct(admin, categoryId, unique(), "1.00");
+ String name = unique();
+
+ for (TestUser user : List.of(simple, readOnly)) {
+ assertError(support.post(user.getJwt(), "/api/v1/categories", categoryBody(name)), 403);
+ assertError(support.put(user.getJwt(), "/api/v1/categories/" + categoryId, categoryBody(name)), 403);
+ assertError(support.delete(user.getJwt(), "/api/v1/categories/" + categoryId), 403);
+ assertError(support.post(user.getJwt(), "/api/v1/products", productBody(categoryId, name, "1.00")), 403);
+ assertError(support.put(user.getJwt(), "/api/v1/products/" + productId, productBody(categoryId, name, "1.00")), 403);
+ assertError(support.delete(user.getJwt(), "/api/v1/products/" + productId), 403);
+ }
+ assertThat(count("SELECT COUNT(*) FROM categories WHERE category_name = ?", name)).isZero();
+ assertThat(count("SELECT COUNT(*) FROM products WHERE id = ?", productId)).isEqualTo(1L);
+ // The simple user has no read permission either, the read-only user does
+ assertError(support.get(simple.getJwt(), "/api/v1/categories"), 403);
+ assertError(support.get(simple.getJwt(), "/api/v1/products"), 403);
+ assertSuccess(support.get(readOnly.getJwt(), "/api/v1/categories/" + categoryId), 200);
+ assertSuccess(support.get(readOnly.getJwt(), "/api/v1/products/" + productId), 200);
+ }
+
+ @Test
+ void _14_ShouldAllowOnlyWhatEachPermissionGrants_WhenUsersHoldASingleCatalogPermission() throws Exception {
+ String admin = support.createAdmin().getJwt();
+ long categoryId = createCategory(admin, unique());
+ TestUser categoryWriter = support.createUser(true, false, support.createRole("CATEGORY:WRITE"));
+ TestUser productWriter = support.createUser(true, false, support.createRole("PRODUCT:WRITE"));
+
+ // CATEGORY:WRITE alone creates categories but neither reads them nor touches products
+ long created = idOf(support.post(categoryWriter.getJwt(), "/api/v1/categories", categoryBody(unique())));
+ assertThat(created).isPositive();
+ assertError(support.get(categoryWriter.getJwt(), "/api/v1/categories/" + created), 403);
+ assertError(support.post(categoryWriter.getJwt(), "/api/v1/products", productBody(categoryId, unique(), "1.00")), 403);
+ // PRODUCT:WRITE alone creates products but cannot create a category
+ createProduct(productWriter.getJwt(), categoryId, unique(), "1.00");
+ assertError(support.post(productWriter.getJwt(), "/api/v1/categories", categoryBody(unique())), 403);
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCatalogMvcTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCatalogMvcTest.java
new file mode 100644
index 0000000..250df27
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCatalogMvcTest.java
@@ -0,0 +1,95 @@
+package com.edgareldy.springjdbctutorial.ws.mvc;
+
+import static org.mockito.Mockito.reset;
+import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+
+import com.edgareldy.springjdbctutorial.core.auth.service.AuthService;
+import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService;
+import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService;
+import com.edgareldy.springjdbctutorial.ws.support.JwtTestSupport;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+
+/**
+ * Common base of the catalog controller tests: builds MockMvc on the shared CatalogMvcTestConfig context, resets
+ * the mocked services before each test and offers request helpers (signed tokens, JSON bodies).
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+// MockMvc + springSecurity() runs the real filter chain in front of the DispatcherServlet (see
+// AbstractRbacMvcTest): the JWT filter and @PreAuthorize are exercised, only the services are mocked.
+@SpringJUnitWebConfig(CatalogMvcTestConfig.class)
+abstract class AbstractCatalogMvcTest {
+
+ @Autowired
+ private WebApplicationContext context;
+ @Autowired
+ protected CategoryService categoryService;
+ @Autowired
+ protected ProductService productService;
+ @Autowired
+ protected AuthService authService;
+
+ protected MockMvc mockMvc;
+
+ @BeforeEach
+ void setUpMockMvc() {
+ reset(categoryService, productService, authService);
+ mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
+ }
+
+ /** A signed dev-key JWT carrying exactly these permissions. */
+ protected static String token(String... permissions) {
+ return JwtTestSupport.tokenWithPermissions(5L, List.of(permissions));
+ }
+
+ /** A signed dev-key JWT carrying every seeded permission except the given one. */
+ protected static String tokenWithEverythingExcept(String permission) {
+ return JwtTestSupport.tokenWithPermissions(5L,
+ JwtTestSupport.ALL_PERMISSIONS.stream().filter(p -> !p.equals(permission)).toList());
+ }
+
+ protected static String bearer(String jwt) {
+ return "Bearer " + jwt;
+ }
+
+ protected MvcResult call(MockHttpServletRequestBuilder request) throws Exception {
+ return mockMvc.perform(request).andReturn();
+ }
+
+ protected static MockHttpServletRequestBuilder getWith(String url, String jwt) {
+ return get(url).header("Authorization", bearer(jwt));
+ }
+
+ protected static MockHttpServletRequestBuilder deleteWith(String url, String jwt) {
+ return delete(url).header("Authorization", bearer(jwt));
+ }
+
+ protected static MockHttpServletRequestBuilder patchWith(String url, String jwt) {
+ return patch(url).header("Authorization", bearer(jwt));
+ }
+
+ protected static MockHttpServletRequestBuilder postJson(String url, String jwt, String body) {
+ return post(url).header("Authorization", bearer(jwt)).contentType(MediaType.APPLICATION_JSON).content(body);
+ }
+
+ protected static MockHttpServletRequestBuilder putJson(String url, String jwt, String body) {
+ return put(url).header("Authorization", bearer(jwt)).contentType(MediaType.APPLICATION_JSON).content(body);
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogEndpointSecurityTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogEndpointSecurityTest.java
new file mode 100644
index 0000000..e220d54
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogEndpointSecurityTest.java
@@ -0,0 +1,135 @@
+package com.edgareldy.springjdbctutorial.ws.mvc;
+
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto;
+import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto;
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.ws.security.JwtService;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+
+/**
+ * Permission matrix of the 10 catalog endpoints of the README table, checked through the real filter chain and
+ * method security: no token gives 401, a revoked token gives 401, a token holding every permission EXCEPT the
+ * required one (or none at all) gives 403, all as ApiResponse errors and without ever reaching the (mocked)
+ * services; the right permission alone gives 2xx.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+class CatalogEndpointSecurityTest extends AbstractCatalogMvcTest {
+
+ private static final String CATEGORY_BODY = "{\"categoryName\":\"Books\"}";
+ private static final String PRODUCT_BODY = "{\"categoryId\":1,\"productName\":\"Novel\",\"unitPrice\":12.50}";
+
+ // Valid bodies on purpose: request validation runs before the method security check, so an invalid body
+ // would answer 400 and hide the permission rule under test.
+ static Stream endpoints() {
+ return Stream.of(
+ Arguments.of(HttpMethod.GET, "/api/v1/categories", null, "CATEGORY:READ"),
+ Arguments.of(HttpMethod.GET, "/api/v1/categories/1", null, "CATEGORY:READ"),
+ Arguments.of(HttpMethod.POST, "/api/v1/categories", CATEGORY_BODY, "CATEGORY:WRITE"),
+ Arguments.of(HttpMethod.PUT, "/api/v1/categories/1", CATEGORY_BODY, "CATEGORY:WRITE"),
+ Arguments.of(HttpMethod.DELETE, "/api/v1/categories/1", null, "CATEGORY:WRITE"),
+ Arguments.of(HttpMethod.GET, "/api/v1/products", null, "PRODUCT:READ"),
+ Arguments.of(HttpMethod.GET, "/api/v1/products/1", null, "PRODUCT:READ"),
+ Arguments.of(HttpMethod.POST, "/api/v1/products", PRODUCT_BODY, "PRODUCT:WRITE"),
+ Arguments.of(HttpMethod.PUT, "/api/v1/products/1", PRODUCT_BODY, "PRODUCT:WRITE"),
+ Arguments.of(HttpMethod.DELETE, "/api/v1/products/1", null, "PRODUCT:WRITE"));
+ }
+
+ private static MockHttpServletRequestBuilder request(HttpMethod method, String url, String body, String jwt) {
+ MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.request(method, url);
+ if (jwt != null) {
+ builder.header("Authorization", bearer(jwt));
+ }
+ if (body != null) {
+ builder.contentType(MediaType.APPLICATION_JSON).content(body);
+ }
+ return builder;
+ }
+
+ @ParameterizedTest(name = "{0} {1}")
+ @MethodSource("endpoints")
+ void _01_ShouldReturn401ApiResponse_WhenNoTokenIsSent(HttpMethod method, String url, String body,
+ String required) throws Exception {
+ MvcResult result = call(request(method, url, body, null));
+
+ assertError(result, 401);
+ verifyNoInteractions(categoryService, productService);
+ }
+
+ @ParameterizedTest(name = "{0} {1} without {3}")
+ @MethodSource("endpoints")
+ void _02_ShouldReturn403ApiResponse_WhenTokenHoldsEveryPermissionExceptTheRequiredOne(
+ HttpMethod method, String url, String body, String required) throws Exception {
+ MvcResult result = call(request(method, url, body, tokenWithEverythingExcept(required)));
+
+ JsonNode json = assertError(result, 403);
+ assertThat(json.get("message").asText()).isEqualTo("Access denied");
+ verifyNoInteractions(categoryService, productService);
+ }
+
+ @ParameterizedTest(name = "{0} {1} with an empty token")
+ @MethodSource("endpoints")
+ void _03_ShouldReturn403ApiResponse_WhenTokenHoldsNoPermissionAtAll(HttpMethod method, String url, String body,
+ String required) throws Exception {
+ MvcResult result = call(request(method, url, body, token()));
+
+ assertError(result, 403);
+ verifyNoInteractions(categoryService, productService);
+ }
+
+ @ParameterizedTest(name = "{0} {1} with a revoked token")
+ @MethodSource("endpoints")
+ void _04_ShouldReturn401_WhenTheTokenIsBlacklisted(HttpMethod method, String url, String body,
+ String required) throws Exception {
+ String jwt = token(required);
+ when(authService.isTokenBlacklisted(JwtService.sha256Hex(jwt))).thenReturn(true);
+
+ assertError(call(request(method, url, body, jwt)), 401);
+ verifyNoInteractions(categoryService, productService);
+ }
+
+ @ParameterizedTest(name = "{0} {1} with {3}")
+ @MethodSource("endpoints")
+ void _05_ShouldNotReturn401Or403_WhenTokenHoldsOnlyTheRequiredPermission(HttpMethod method, String url,
+ String body, String required)
+ throws Exception {
+ // Mocks return null for get/create/update, which would make the controller fail on the converter, so
+ // give the read paths a real answer; the point of this test is only that the permission opens the door.
+ when(categoryService.list(0, 20)).thenReturn(new PageDto(List.of(), 0, 20, 0));
+ when(categoryService.get(1L)).thenReturn(new CategoryDto(1L, "Books"));
+ when(categoryService.create(any())).thenReturn(new CategoryDto(1L, "Books"));
+ when(categoryService.update(eq(1L), any()))
+ .thenReturn(new CategoryDto(1L, "Books"));
+ ProductDto product = new ProductDto(1L, 1L, "Novel", new BigDecimal("12.50"));
+ when(productService.list(0, 20, null)).thenReturn(new PageDto(List.of(), 0, 20, 0));
+ when(productService.get(1L)).thenReturn(product);
+ when(productService.create(any())).thenReturn(product);
+ when(productService.update(eq(1L), any()))
+ .thenReturn(product);
+
+ MvcResult result = call(request(method, url, body, token(required)));
+
+ assertThat(result.getResponse().getStatus()).isIn(200, 201);
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogMvcTestConfig.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogMvcTestConfig.java
new file mode 100644
index 0000000..91f99c9
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogMvcTestConfig.java
@@ -0,0 +1,93 @@
+package com.edgareldy.springjdbctutorial.ws.mvc;
+
+import static org.mockito.Mockito.mock;
+
+import com.edgareldy.springjdbctutorial.core.auth.service.AuthService;
+import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService;
+import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService;
+import com.edgareldy.springjdbctutorial.ws.config.SecurityConfig;
+import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig;
+import com.edgareldy.springjdbctutorial.ws.controller.CategoryController;
+import com.edgareldy.springjdbctutorial.ws.controller.ProductController;
+import com.edgareldy.springjdbctutorial.ws.exception.GlobalExceptionHandler;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.time.Clock;
+import java.util.List;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.validation.Validator;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Shared test context of the catalog controller tests: MVC, the REAL SecurityConfig (JWT filter, method security
+ * with CustomPermissionEvaluator, 401/403 handlers), the real controllers and advice, and Mockito mocks in
+ * place of CategoryService and ProductService (no database).
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+// Same recipe as RbacMvcTestConfig, kept in ws.mvc and never in ws.controller so WebMvcConfig's component scan
+// cannot pick it up. The real controllers are declared as beans wired to the mocked services.
+@Configuration
+@EnableWebMvc
+@Import(SecurityConfig.class)
+public class CatalogMvcTestConfig implements WebMvcConfigurer {
+
+ private final WebMvcConfig real = new WebMvcConfig();
+
+ @Bean
+ Clock clock() {
+ return Clock.systemUTC();
+ }
+
+ @Bean
+ ObjectMapper objectMapper() {
+ return real.objectMapper();
+ }
+
+ // The security filter chain needs an AuthService to ask "is this token blacklisted?"
+ @Bean
+ AuthService authService() {
+ return mock(AuthService.class);
+ }
+
+ @Bean
+ CategoryService categoryService() {
+ return mock(CategoryService.class);
+ }
+
+ @Bean
+ ProductService productService() {
+ return mock(ProductService.class);
+ }
+
+ @Bean
+ CategoryController categoryController(CategoryService categoryService) {
+ return new CategoryController(categoryService);
+ }
+
+ @Bean
+ ProductController productController(ProductService productService) {
+ return new ProductController(productService);
+ }
+
+ @Bean
+ GlobalExceptionHandler globalExceptionHandler() {
+ return new GlobalExceptionHandler();
+ }
+
+ @Override
+ public void extendMessageConverters(List> converters) {
+ real.extendMessageConverters(converters);
+ }
+
+ @Override
+ public Validator getValidator() {
+ return real.getValidator();
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CategoryControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CategoryControllerTest.java
new file mode 100644
index 0000000..28c3280
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CategoryControllerTest.java
@@ -0,0 +1,268 @@
+package com.edgareldy.springjdbctutorial.ws.mvc;
+
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError;
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto;
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException;
+import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+
+/**
+ * MockMvc tests of CategoryController with CategoryService mocked but the real security and validation: statuses,
+ * ApiResponse shape, page/size bounds, per-field body validation and the 404/422 refusals.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+class CategoryControllerTest extends AbstractCatalogMvcTest {
+
+ private static final String READ = "CATEGORY:READ";
+ private static final String WRITE = "CATEGORY:WRITE";
+ private static final String VALID_BODY = "{\"categoryName\":\"Books\"}";
+
+ // ---------------------------------------------------------------- GET /categories
+
+ @Test
+ void _01_ShouldReturn200WithPageAndDefaults_WhenTokenHoldsCategoryRead() throws Exception {
+ when(categoryService.list(0, 20)).thenReturn(new PageDto<>(List.of(new CategoryDto(1L, "Books")), 0, 20, 1));
+
+ JsonNode json = assertSuccess(call(getWith("/api/v1/categories", token(READ))), 200);
+
+ JsonNode data = json.get("data");
+ assertThat(data.get("content").get(0).get("id").asLong()).isEqualTo(1L);
+ assertThat(data.get("content").get(0).get("categoryName").asText()).isEqualTo("Books");
+ assertThat(data.get("page").asInt()).isZero();
+ assertThat(data.get("size").asInt()).isEqualTo(20);
+ assertThat(data.get("totalElements").asLong()).isEqualTo(1L);
+ assertThat(data.get("totalPages").asInt()).isEqualTo(1);
+ }
+
+ @Test
+ void _02_ShouldPassPageAndSizeToTheService_WhenTheyAreGiven() throws Exception {
+ when(categoryService.list(2, 5)).thenReturn(new PageDto<>(List.of(), 2, 5, 11));
+
+ JsonNode json = assertSuccess(call(getWith("/api/v1/categories?page=2&size=5", token(READ))), 200);
+
+ assertThat(json.get("data").get("page").asInt()).isEqualTo(2);
+ assertThat(json.get("data").get("size").asInt()).isEqualTo(5);
+ verify(categoryService).list(2, 5);
+ }
+
+ @Test
+ void _03_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception {
+ when(categoryService.list(99, 10)).thenReturn(new PageDto<>(List.of(), 99, 10, 3));
+
+ JsonNode data = assertSuccess(call(getWith("/api/v1/categories?page=99&size=10", token(READ))), 200).get("data");
+
+ assertThat(data.has("content")).isTrue();
+ assertThat(data.get("content").isArray()).isTrue();
+ assertThat(data.get("content")).isEmpty();
+ assertThat(data.get("totalElements").asLong()).isEqualTo(3L);
+ }
+
+ @Test
+ void _04_ShouldReturn400NamingPage_WhenPageIsNegative() throws Exception {
+ JsonNode json = assertError(call(getWith("/api/v1/categories?page=-1", token(READ))), 400);
+
+ assertThat(json.get("message").asText()).contains("page: ").doesNotContain("size: ");
+ verifyNoInteractions(categoryService);
+ }
+
+ @Test
+ void _05_ShouldReturn400NamingSize_WhenSizeIsZeroOrAbove100() throws Exception {
+ JsonNode zero = assertError(call(getWith("/api/v1/categories?size=0", token(READ))), 400);
+ JsonNode big = assertError(call(getWith("/api/v1/categories?size=101", token(READ))), 400);
+
+ assertThat(zero.get("message").asText()).contains("size: ").doesNotContain("page: ");
+ assertThat(big.get("message").asText()).contains("size: ");
+ verifyNoInteractions(categoryService);
+ }
+
+ @Test
+ void _06_ShouldReturn400NamingBoth_WhenPageAndSizeAreInvalid() throws Exception {
+ JsonNode json = assertError(call(getWith("/api/v1/categories?page=-5&size=500", token(READ))), 400);
+
+ assertThat(json.get("message").asText()).contains("page: ").contains("size: ");
+ }
+
+ @Test
+ void _07_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() throws Exception {
+ when(categoryService.list(0, 1)).thenReturn(new PageDto<>(List.of(), 0, 1, 0));
+ when(categoryService.list(0, 100)).thenReturn(new PageDto<>(List.of(), 0, 100, 0));
+
+ assertSuccess(call(getWith("/api/v1/categories?size=1", token(READ))), 200);
+ assertSuccess(call(getWith("/api/v1/categories?size=100", token(READ))), 200);
+ }
+
+ // ---------------------------------------------------------------- GET /categories/{id}
+
+ @Test
+ void _08_ShouldReturn200_WhenCategoryExists() throws Exception {
+ when(categoryService.get(2L)).thenReturn(new CategoryDto(2L, "Games"));
+
+ JsonNode json = assertSuccess(call(getWith("/api/v1/categories/2", token(READ))), 200);
+
+ assertThat(json.get("data").get("id").asLong()).isEqualTo(2L);
+ assertThat(json.get("data").get("categoryName").asText()).isEqualTo("Games");
+ }
+
+ @Test
+ void _09_ShouldReturn404ApiResponse_WhenCategoryDoesNotExist() throws Exception {
+ when(categoryService.get(99L)).thenThrow(new ResourceNotFoundException("Category not found: 99"));
+
+ JsonNode json = assertError(call(getWith("/api/v1/categories/99", token(READ))), 404);
+
+ assertThat(json.get("message").asText()).isEqualTo("Category not found: 99");
+ }
+
+ @Test
+ void _10_ShouldReturn400_WhenCategoryIdIsNotANumber() throws Exception {
+ assertError(call(getWith("/api/v1/categories/abc", token(READ))), 400);
+ verifyNoInteractions(categoryService);
+ }
+
+ // ---------------------------------------------------------------- POST /categories
+
+ @Test
+ void _11_ShouldReturn201WithCreatedCategory_WhenBodyIsValid() throws Exception {
+ when(categoryService.create(any(CategoryDto.class))).thenReturn(new CategoryDto(7L, "Books"));
+
+ JsonNode json = assertSuccess(call(postJson("/api/v1/categories", token(WRITE), VALID_BODY)), 201);
+
+ assertThat(json.get("data").get("id").asLong()).isEqualTo(7L);
+ assertThat(json.get("message").asText()).isEqualTo("Category created");
+ ArgumentCaptor sent = ArgumentCaptor.forClass(CategoryDto.class);
+ verify(categoryService).create(sent.capture());
+ assertThat(sent.getValue().getCategoryName()).isEqualTo("Books");
+ }
+
+ @Test
+ void _12_ShouldReturn400NamingTheField_WhenNameIsBlank() throws Exception {
+ JsonNode json = assertError(call(postJson("/api/v1/categories", token(WRITE), "{\"categoryName\":\" \"}")), 400);
+
+ assertThat(json.get("message").asText()).contains("categoryName: ");
+ verifyNoInteractions(categoryService);
+ }
+
+ @Test
+ void _13_ShouldReturn400NamingTheField_WhenNameIsMissingOrTooLong() throws Exception {
+ JsonNode missing = assertError(call(postJson("/api/v1/categories", token(WRITE), "{}")), 400);
+ JsonNode tooLong = assertError(call(postJson("/api/v1/categories", token(WRITE),
+ "{\"categoryName\":\"" + "x".repeat(101) + "\"}")), 400);
+
+ assertThat(missing.get("message").asText()).contains("categoryName: ");
+ assertThat(tooLong.get("message").asText()).contains("categoryName: ");
+ verifyNoInteractions(categoryService);
+ }
+
+ @Test
+ void _14_ShouldReturn400_WhenBodyIsMalformedJson() throws Exception {
+ assertError(call(postJson("/api/v1/categories", token(WRITE), "{\"categoryName\":")), 400);
+ verifyNoInteractions(categoryService);
+ }
+
+ @Test
+ void _15_ShouldReturn422_WhenServiceRefusesADuplicateName() throws Exception {
+ when(categoryService.create(any(CategoryDto.class)))
+ .thenThrow(new BusinessRuleException("Category name already exists"));
+
+ JsonNode json = assertError(call(postJson("/api/v1/categories", token(WRITE), VALID_BODY)), 422);
+
+ assertThat(json.get("message").asText()).isEqualTo("Category name already exists");
+ }
+
+ @Test
+ void _16_ShouldReturn415_WhenContentTypeIsNotJson() throws Exception {
+ assertError(call(MockMvcRequestBuilders.post("/api/v1/categories")
+ .header("Authorization", bearer(token(WRITE))).contentType(MediaType.TEXT_PLAIN).content("Books")), 415);
+ verifyNoInteractions(categoryService);
+ }
+
+ // ---------------------------------------------------------------- PUT /categories/{id}
+
+ @Test
+ void _17_ShouldReturn200WithUpdatedCategory_WhenBodyIsValid() throws Exception {
+ when(categoryService.update(eq(2L), any(CategoryDto.class))).thenReturn(new CategoryDto(2L, "Books"));
+
+ JsonNode json = assertSuccess(call(putJson("/api/v1/categories/2", token(WRITE), VALID_BODY)), 200);
+
+ assertThat(json.get("data").get("categoryName").asText()).isEqualTo("Books");
+ assertThat(json.get("message").asText()).isEqualTo("Category updated");
+ }
+
+ @Test
+ void _18_ShouldReturn404ApiResponse_WhenUpdatingAnUnknownCategory() throws Exception {
+ when(categoryService.update(eq(99L), any(CategoryDto.class)))
+ .thenThrow(new ResourceNotFoundException("Category not found: 99"));
+
+ assertError(call(putJson("/api/v1/categories/99", token(WRITE), VALID_BODY)), 404);
+ }
+
+ @Test
+ void _19_ShouldReturn422_WhenRenamingToAnExistingName() throws Exception {
+ when(categoryService.update(eq(2L), any(CategoryDto.class)))
+ .thenThrow(new BusinessRuleException("Category name already exists"));
+
+ assertError(call(putJson("/api/v1/categories/2", token(WRITE), VALID_BODY)), 422);
+ }
+
+ @Test
+ void _20_ShouldReturn400_WhenUpdatedNameIsBlank() throws Exception {
+ JsonNode json = assertError(call(putJson("/api/v1/categories/2", token(WRITE), "{\"categoryName\":\"\"}")), 400);
+
+ assertThat(json.get("message").asText()).contains("categoryName: ");
+ verifyNoInteractions(categoryService);
+ }
+
+ // ---------------------------------------------------------------- DELETE /categories/{id}
+
+ @Test
+ void _21_ShouldReturn200WithoutData_WhenCategoryIsDeleted() throws Exception {
+ JsonNode json = assertSuccess(call(deleteWith("/api/v1/categories/2", token(WRITE))), 200);
+
+ assertThat(json.get("message").asText()).isEqualTo("Category deleted");
+ assertThat(json.get("data") == null || json.get("data").isNull()).isTrue();
+ verify(categoryService).delete(2L);
+ }
+
+ @Test
+ void _22_ShouldReturn404ApiResponse_WhenDeletingAnUnknownCategory() throws Exception {
+ doThrow(new ResourceNotFoundException("Category not found: 99"))
+ .when(categoryService).delete(99L);
+
+ assertError(call(deleteWith("/api/v1/categories/99", token(WRITE))), 404);
+ }
+
+ @Test
+ void _23_ShouldReturn422_WhenCategoryStillHasProducts() throws Exception {
+ doThrow(new BusinessRuleException("Category still has products"))
+ .when(categoryService).delete(2L);
+
+ JsonNode json = assertError(call(deleteWith("/api/v1/categories/2", token(WRITE))), 422);
+
+ assertThat(json.get("message").asText()).isEqualTo("Category still has products");
+ }
+
+ // ---------------------------------------------------------------- unsupported method
+
+ @Test
+ void _24_ShouldReturn405ApiResponse_WhenPatchIsUsedOnACategory() throws Exception {
+ assertError(call(patchWith("/api/v1/categories/2", token(WRITE))), 405);
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/ProductControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/ProductControllerTest.java
new file mode 100644
index 0000000..9ef2a61
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/ProductControllerTest.java
@@ -0,0 +1,317 @@
+package com.edgareldy.springjdbctutorial.ws.mvc;
+
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError;
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto;
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException;
+import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.math.BigDecimal;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+
+/**
+ * MockMvc tests of ProductController with ProductService mocked but the real security and validation: statuses,
+ * ApiResponse shape, page/size bounds, the categoryId filter, per-field body validation (price rules included)
+ * and the 404/422 refusals.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+class ProductControllerTest extends AbstractCatalogMvcTest {
+
+ private static final String READ = "PRODUCT:READ";
+ private static final String WRITE = "PRODUCT:WRITE";
+ private static final String VALID_BODY = "{\"categoryId\":2,\"productName\":\"Chess\",\"unitPrice\":25.99}";
+
+ private static ProductDto chess() {
+ return new ProductDto(3L, 2L, "Chess", new BigDecimal("25.99"));
+ }
+
+ private static String body(String categoryId, String name, String price) {
+ return "{\"categoryId\":" + categoryId + ",\"productName\":" + name + ",\"unitPrice\":" + price + "}";
+ }
+
+ // ---------------------------------------------------------------- GET /products
+
+ @Test
+ void _01_ShouldReturn200WithPageAndDefaults_WhenTokenHoldsProductRead() throws Exception {
+ when(productService.list(0, 20, null)).thenReturn(new PageDto<>(List.of(chess()), 0, 20, 1));
+
+ JsonNode data = assertSuccess(call(getWith("/api/v1/products", token(READ))), 200).get("data");
+
+ JsonNode first = data.get("content").get(0);
+ assertThat(first.get("id").asLong()).isEqualTo(3L);
+ assertThat(first.get("categoryId").asLong()).isEqualTo(2L);
+ assertThat(first.get("productName").asText()).isEqualTo("Chess");
+ assertThat(first.get("unitPrice").decimalValue()).isEqualByComparingTo("25.99");
+ assertThat(data.get("page").asInt()).isZero();
+ assertThat(data.get("size").asInt()).isEqualTo(20);
+ assertThat(data.get("totalElements").asLong()).isEqualTo(1L);
+ }
+
+ @Test
+ void _02_ShouldPassTheCategoryIdFilterToTheService_WhenItIsGiven() throws Exception {
+ when(productService.list(1, 5, 2L)).thenReturn(new PageDto<>(List.of(chess()), 1, 5, 6));
+
+ assertSuccess(call(getWith("/api/v1/products?page=1&size=5&categoryId=2", token(READ))), 200);
+
+ verify(productService).list(1, 5, 2L);
+ }
+
+ @Test
+ void _03_ShouldPassNullFilter_WhenCategoryIdIsAbsent() throws Exception {
+ when(productService.list(0, 20, null)).thenReturn(new PageDto<>(List.of(), 0, 20, 0));
+
+ assertSuccess(call(getWith("/api/v1/products", token(READ))), 200);
+
+ verify(productService).list(0, 20, null);
+ }
+
+ @Test
+ void _04_ShouldReturn400_WhenCategoryIdIsNotANumber() throws Exception {
+ JsonNode json = assertError(call(getWith("/api/v1/products?categoryId=abc", token(READ))), 400);
+
+ assertThat(json.get("message").asText()).isEqualTo("Invalid value for parameter: categoryId");
+ verifyNoInteractions(productService);
+ }
+
+ @Test
+ void _05_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception {
+ when(productService.list(99, 10, null)).thenReturn(new PageDto<>(List.of(), 99, 10, 4));
+
+ JsonNode data = assertSuccess(call(getWith("/api/v1/products?page=99&size=10", token(READ))), 200).get("data");
+
+ assertThat(data.get("content").isArray()).isTrue();
+ assertThat(data.get("content")).isEmpty();
+ assertThat(data.get("totalElements").asLong()).isEqualTo(4L);
+ }
+
+ @Test
+ void _06_ShouldReturn400NamingPage_WhenPageIsNegative() throws Exception {
+ JsonNode json = assertError(call(getWith("/api/v1/products?page=-1", token(READ))), 400);
+
+ assertThat(json.get("message").asText()).contains("page: ").doesNotContain("size: ");
+ verifyNoInteractions(productService);
+ }
+
+ @Test
+ void _07_ShouldReturn400NamingSize_WhenSizeIsZeroOrAbove100() throws Exception {
+ JsonNode zero = assertError(call(getWith("/api/v1/products?size=0", token(READ))), 400);
+ JsonNode big = assertError(call(getWith("/api/v1/products?size=101", token(READ))), 400);
+
+ assertThat(zero.get("message").asText()).contains("size: ").doesNotContain("page: ");
+ assertThat(big.get("message").asText()).contains("size: ");
+ verifyNoInteractions(productService);
+ }
+
+ @Test
+ void _08_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() throws Exception {
+ when(productService.list(0, 1, null)).thenReturn(new PageDto<>(List.of(), 0, 1, 0));
+ when(productService.list(0, 100, null)).thenReturn(new PageDto<>(List.of(), 0, 100, 0));
+
+ assertSuccess(call(getWith("/api/v1/products?size=1", token(READ))), 200);
+ assertSuccess(call(getWith("/api/v1/products?size=100", token(READ))), 200);
+ }
+
+ // ---------------------------------------------------------------- GET /products/{id}
+
+ @Test
+ void _09_ShouldReturn200_WhenProductExists() throws Exception {
+ when(productService.get(3L)).thenReturn(chess());
+
+ JsonNode json = assertSuccess(call(getWith("/api/v1/products/3", token(READ))), 200);
+
+ assertThat(json.get("data").get("productName").asText()).isEqualTo("Chess");
+ }
+
+ @Test
+ void _10_ShouldReturn404ApiResponse_WhenProductDoesNotExist() throws Exception {
+ when(productService.get(99L)).thenThrow(new ResourceNotFoundException("Product not found: 99"));
+
+ JsonNode json = assertError(call(getWith("/api/v1/products/99", token(READ))), 404);
+
+ assertThat(json.get("message").asText()).isEqualTo("Product not found: 99");
+ }
+
+ // ---------------------------------------------------------------- POST /products
+
+ @Test
+ void _11_ShouldReturn201WithCreatedProduct_WhenBodyIsValid() throws Exception {
+ when(productService.create(any(ProductDto.class))).thenReturn(chess());
+
+ JsonNode json = assertSuccess(call(postJson("/api/v1/products", token(WRITE), VALID_BODY)), 201);
+
+ assertThat(json.get("data").get("id").asLong()).isEqualTo(3L);
+ assertThat(json.get("message").asText()).isEqualTo("Product created");
+ ArgumentCaptor sent = ArgumentCaptor.forClass(ProductDto.class);
+ verify(productService).create(sent.capture());
+ assertThat(sent.getValue().getCategoryId()).isEqualTo(2L);
+ assertThat(sent.getValue().getProductName()).isEqualTo("Chess");
+ assertThat(sent.getValue().getUnitPrice()).isEqualByComparingTo("25.99");
+ }
+
+ @Test
+ void _12_ShouldReturn400NamingEveryField_WhenBodyIsEmpty() throws Exception {
+ JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), "{}")), 400);
+
+ assertThat(json.get("message").asText()).contains("categoryId: ").contains("productName: ")
+ .contains("unitPrice: ");
+ verifyNoInteractions(productService);
+ }
+
+ @Test
+ void _13_ShouldReturn400NamingOnlyProductName_WhenNameIsBlankOrTooLong() throws Exception {
+ JsonNode blank = assertError(call(postJson("/api/v1/products", token(WRITE), body("2", "\" \"", "1.00"))), 400);
+ JsonNode tooLong = assertError(call(postJson("/api/v1/products", token(WRITE),
+ body("2", "\"" + "x".repeat(151) + "\"", "1.00"))), 400);
+
+ assertThat(blank.get("message").asText()).contains("productName: ").doesNotContain("unitPrice: ");
+ assertThat(tooLong.get("message").asText()).contains("productName: ").doesNotContain("unitPrice: ");
+ verifyNoInteractions(productService);
+ }
+
+ @Test
+ void _14_ShouldReturn400NamingUnitPrice_WhenPriceIsNegative() throws Exception {
+ JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Chess\"", "-0.01"))), 400);
+
+ assertThat(json.get("message").asText()).contains("unitPrice: ").doesNotContain("productName: ");
+ verifyNoInteractions(productService);
+ }
+
+ @Test
+ void _15_ShouldReturn400NamingUnitPrice_WhenPriceHasThreeDecimals() throws Exception {
+ JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Chess\"", "1.005"))), 400);
+
+ assertThat(json.get("message").asText()).contains("unitPrice: ");
+ verifyNoInteractions(productService);
+ }
+
+ @Test
+ void _16_ShouldReturn400NamingUnitPrice_WhenPriceHasMoreThanTenIntegerDigits() throws Exception {
+ JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE),
+ body("2", "\"Chess\"", "10000000000.00"))), 400);
+
+ assertThat(json.get("message").asText()).contains("unitPrice: ");
+ verifyNoInteractions(productService);
+ }
+
+ @Test
+ void _17_ShouldAcceptTheMaximumPriceAndZero_WhenOnTheBoundaries() throws Exception {
+ when(productService.create(any(ProductDto.class))).thenReturn(chess());
+
+ assertSuccess(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Gold\"", "9999999999.99"))), 201);
+ assertSuccess(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Free\"", "0"))), 201);
+ }
+
+ @Test
+ void _18_ShouldReturn400_WhenPriceIsNotANumber() throws Exception {
+ assertError(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Chess\"", "\"abc\""))), 400);
+ verifyNoInteractions(productService);
+ }
+
+ @Test
+ void _19_ShouldReturn404_WhenServiceReportsAnUnknownCategory() throws Exception {
+ when(productService.create(any(ProductDto.class)))
+ .thenThrow(new ResourceNotFoundException("Category not found: 2"));
+
+ JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), VALID_BODY)), 404);
+
+ assertThat(json.get("message").asText()).isEqualTo("Category not found: 2");
+ }
+
+ @Test
+ void _20_ShouldReturn415_WhenContentTypeIsNotJson() throws Exception {
+ assertError(call(MockMvcRequestBuilders.post("/api/v1/products")
+ .header("Authorization", bearer(token(WRITE))).contentType(MediaType.TEXT_PLAIN).content("Chess")), 415);
+ verifyNoInteractions(productService);
+ }
+
+ // ---------------------------------------------------------------- PUT /products/{id}
+
+ @Test
+ void _21_ShouldReturn200WithUpdatedProduct_WhenBodyIsValid() throws Exception {
+ when(productService.update(eq(3L), any(ProductDto.class))).thenReturn(chess());
+
+ JsonNode json = assertSuccess(call(putJson("/api/v1/products/3", token(WRITE), VALID_BODY)), 200);
+
+ assertThat(json.get("message").asText()).isEqualTo("Product updated");
+ assertThat(json.get("data").get("unitPrice").decimalValue()).isEqualByComparingTo("25.99");
+ }
+
+ @Test
+ void _22_ShouldReturn404_WhenUpdatingAnUnknownProductOrCategory() throws Exception {
+ when(productService.update(eq(99L), any(ProductDto.class)))
+ .thenThrow(new ResourceNotFoundException("Product not found: 99"));
+ when(productService.update(eq(3L), any(ProductDto.class)))
+ .thenThrow(new ResourceNotFoundException("Category not found: 2"));
+
+ assertError(call(putJson("/api/v1/products/99", token(WRITE), VALID_BODY)), 404);
+ assertError(call(putJson("/api/v1/products/3", token(WRITE), VALID_BODY)), 404);
+ }
+
+ @Test
+ void _23_ShouldReturn422_WhenServiceRefusesTheUpdate() throws Exception {
+ when(productService.update(eq(3L), any(ProductDto.class)))
+ .thenThrow(new BusinessRuleException("Unit price must not be negative"));
+
+ assertError(call(putJson("/api/v1/products/3", token(WRITE), VALID_BODY)), 422);
+ }
+
+ @Test
+ void _24_ShouldReturn400_WhenUpdatedBodyIsInvalid() throws Exception {
+ JsonNode json = assertError(call(putJson("/api/v1/products/3", token(WRITE), body("null", "\"\"", "-1"))), 400);
+
+ assertThat(json.get("message").asText()).contains("categoryId: ").contains("productName: ")
+ .contains("unitPrice: ");
+ verifyNoInteractions(productService);
+ }
+
+ // ---------------------------------------------------------------- DELETE /products/{id}
+
+ @Test
+ void _25_ShouldReturn200WithoutData_WhenProductIsDeleted() throws Exception {
+ JsonNode json = assertSuccess(call(deleteWith("/api/v1/products/3", token(WRITE))), 200);
+
+ assertThat(json.get("message").asText()).isEqualTo("Product deleted");
+ assertThat(json.get("data") == null || json.get("data").isNull()).isTrue();
+ verify(productService).delete(3L);
+ }
+
+ @Test
+ void _26_ShouldReturn404ApiResponse_WhenDeletingAnUnknownProduct() throws Exception {
+ doThrow(new ResourceNotFoundException("Product not found: 99")).when(productService).delete(99L);
+
+ assertError(call(deleteWith("/api/v1/products/99", token(WRITE))), 404);
+ }
+
+ @Test
+ void _27_ShouldReturn422_WhenAnOrderReferencesTheProduct() throws Exception {
+ doThrow(new BusinessRuleException("Product is referenced by orders")).when(productService).delete(3L);
+
+ JsonNode json = assertError(call(deleteWith("/api/v1/products/3", token(WRITE))), 422);
+
+ assertThat(json.get("message").asText()).isEqualTo("Product is referenced by orders");
+ }
+
+ // ---------------------------------------------------------------- unsupported method
+
+ @Test
+ void _28_ShouldReturn405ApiResponse_WhenPatchIsUsedOnAProduct() throws Exception {
+ assertError(call(patchWith("/api/v1/products/3", token(WRITE))), 405);
+ }
+}