diff --git a/README.md b/README.md
index 6cd8409..df312b7 100644
--- a/README.md
+++ b/README.md
@@ -209,7 +209,7 @@ spring-jdbc-tutorial/
│ ├── dto/ (OrderDto.java)
│ ├── mapper/ (OrderRowMapper.java, OrderMapper.java)
│ ├── dao/ (OrderDao.java + impl/)
-│ ├── service/ (OrderService.java + impl/, depends on CategoryService/ProductService/CustomerService)
+│ ├── service/ (OrderService.java + impl/, depends on ProductService/CustomerService only: the category is never needed)
│ └── config/ (DaoConfig.java, ServiceConfig.java)
└── ws/
├── pom.xml (depends on core/auth, core/catalog, core/customer, core/order)
@@ -438,11 +438,11 @@ Full CRUD for users, roles, and permissions, still inside `core/auth`. Assignmen
### Tasks
-- [ ] `core/order`: `Order` entity (`@Table("orders")`) and `OrderDto`, `OrderRowMapper` (entity) plus `OrderMapper` (entity ↔ dto), `OrderDao` + impl, `OrderService` + impl (depends on `catalog`'s `ProductService` and `customer`'s `CustomerService` - their service interfaces, never their DAOs directly): computes `total = quantity * product.unitPrice`, checks the referenced customer/product exist
-- [ ] `core/order`'s `DaoConfig`/`ServiceConfig` (the latter injecting `ProductService`/`CustomerService` beans from the other modules' contexts)
-- [ ] `ws`: `OrderController`, `OrderConverter`
-- [ ] `WebMvcConfig` updated to import `core/order`'s config
-- [ ] Tests at all five layers, `OrderDao` with its own fixture dataset, the total computation, and the not-found cases for a bad `customerId`/`productId`
+- [x] `core/order`: `Order` entity (`@Table("orders")`) and `OrderDto`, `OrderRowMapper` (entity) plus `OrderMapper` (entity ↔ dto), `OrderDao` + impl, `OrderService` + impl (depends on `catalog`'s `ProductService` and `customer`'s `CustomerService` - their service interfaces, never their DAOs directly): computes `total = quantity * product.unitPrice`, checks the referenced customer/product exist
+- [x] `core/order`'s `DaoConfig`/`ServiceConfig` (the latter injecting `ProductService`/`CustomerService` beans from the other modules' contexts)
+- [x] `ws`: `OrderController`, `OrderConverter`
+- [x] `WebMvcConfig` updated to import `core/order`'s config
+- [x] Tests at all five layers, `OrderDao` with its own fixture dataset, the total computation, and the not-found cases for a bad `customerId`/`productId`
## Order of work
diff --git a/core/order/pom.xml b/core/order/pom.xml
index a3ac330..35456e2 100644
--- a/core/order/pom.xml
+++ b/core/order/pom.xml
@@ -23,5 +23,22 @@
com.edgareldy.springjdbctutorial
customer
+
+
+ com.edgareldy.springjdbctutorial
+ common
+ test-jar
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+ org.testcontainers
+ postgresql
+ test
+
diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/DaoConfig.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/DaoConfig.java
new file mode 100644
index 0000000..8ec6e9e
--- /dev/null
+++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/DaoConfig.java
@@ -0,0 +1,24 @@
+package com.edgareldy.springjdbctutorial.core.order.config;
+
+import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao;
+import com.edgareldy.springjdbctutorial.core.order.dao.impl.OrderDaoImpl;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+/**
+ * Declares the DAO beans of the order 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 OrderDao orderDao(JdbcTemplate jdbcTemplate) {
+ return new OrderDaoImpl(jdbcTemplate);
+ }
+}
diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/ServiceConfig.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/ServiceConfig.java
new file mode 100644
index 0000000..41d2033
--- /dev/null
+++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/ServiceConfig.java
@@ -0,0 +1,31 @@
+package com.edgareldy.springjdbctutorial.core.order.config;
+
+import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService;
+import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService;
+import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao;
+import com.edgareldy.springjdbctutorial.core.order.service.OrderService;
+import com.edgareldy.springjdbctutorial.core.order.service.impl.OrderServiceImpl;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+
+/**
+ * Declares the service beans of the order module. Imports its DaoConfig and the catalog and customer
+ * ServiceConfig (fully qualified, every module has a class of that name) 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,
+ com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class,
+ com.edgareldy.springjdbctutorial.core.customer.config.ServiceConfig.class})
+public class ServiceConfig {
+
+ @Bean
+ public OrderService orderService(OrderDao orderDao, ProductService productService, CustomerService customerService) {
+ return new OrderServiceImpl(orderDao, productService, customerService);
+ }
+}
diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/OrderDao.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/OrderDao.java
new file mode 100644
index 0000000..857b863
--- /dev/null
+++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/OrderDao.java
@@ -0,0 +1,28 @@
+package com.edgareldy.springjdbctutorial.core.order.dao;
+
+import com.edgareldy.springjdbctutorial.core.order.entity.Order;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Data access for orders. Entity types only. Orders are immutable: there is no update and no delete.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public interface OrderDao {
+
+ /** One page of orders ordered by id, each filter is applied only when not null. */
+ List findPage(int page, int size, Long customerId, Long productId);
+
+ /** Number of orders matching the same optional filters. */
+ long count(Long customerId, Long productId);
+
+ Optional findById(Long id);
+
+ /** Inserts the order and returns it with its generated id. */
+ Order insert(Order order);
+}
diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImpl.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImpl.java
new file mode 100644
index 0000000..115d176
--- /dev/null
+++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImpl.java
@@ -0,0 +1,78 @@
+package com.edgareldy.springjdbctutorial.core.order.dao.impl;
+
+import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao;
+import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao;
+import com.edgareldy.springjdbctutorial.core.order.entity.Order;
+import com.edgareldy.springjdbctutorial.core.order.mapper.OrderRowMapper;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * JdbcTemplate implementation of OrderDao. 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 OrderDaoImpl extends AbstractDao implements OrderDao {
+
+ private static final String SELECT = "SELECT id, customer_id, product_id, quantity, total FROM orders";
+
+ private final OrderRowMapper rowMapper = new OrderRowMapper();
+
+ public OrderDaoImpl(JdbcTemplate jdbcTemplate) {
+ super(jdbcTemplate);
+ }
+
+ @Override
+ public List findPage(int page, int size, Long customerId, Long productId) {
+ List args = new ArrayList<>();
+ String where = where(customerId, productId, args);
+ args.add(size);
+ args.add((long) page * size);
+ return getJdbcTemplate().query(SELECT + where + " ORDER BY id LIMIT ? OFFSET ?", rowMapper, args.toArray());
+ }
+
+ @Override
+ public long count(Long customerId, Long productId) {
+ List args = new ArrayList<>();
+ String where = where(customerId, productId, args);
+ Long total = getJdbcTemplate().queryForObject("SELECT COUNT(*) FROM orders" + where, Long.class, args.toArray());
+ return total == null ? 0L : total;
+ }
+
+ @Override
+ public Optional findById(Long id) {
+ return findOne(SELECT + " WHERE id = ?", rowMapper, id);
+ }
+
+ @Override
+ public Order insert(Order order) {
+ long id = insertReturningId(
+ "INSERT INTO orders (customer_id, product_id, quantity, total) VALUES (?, ?, ?, ?) RETURNING id",
+ order.getCustomerId(), order.getProductId(), order.getQuantity(), order.getTotal());
+ order.setId(id);
+ return order;
+ }
+
+ /**
+ * Builds the WHERE clause from the non-null filters. Only fixed column names are concatenated,
+ * the values are always bound as parameters and appended to args in placeholder order.
+ */
+ private static String where(Long customerId, Long productId, List args) {
+ StringBuilder where = new StringBuilder();
+ if (customerId != null) {
+ where.append(" WHERE customer_id = ?");
+ args.add(customerId);
+ }
+ if (productId != null) {
+ where.append(customerId == null ? " WHERE" : " AND").append(" product_id = ?");
+ args.add(productId);
+ }
+ return where.toString();
+ }
+}
diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dto/OrderDto.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dto/OrderDto.java
new file mode 100644
index 0000000..a56053c
--- /dev/null
+++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dto/OrderDto.java
@@ -0,0 +1,105 @@
+package com.edgareldy.springjdbctutorial.core.order.dto;
+
+import java.math.BigDecimal;
+import java.util.Objects;
+
+/**
+ * Business shape of an order. The total is computed by the service, never taken from the caller.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class OrderDto {
+
+ private Long id;
+
+ private Long customerId;
+
+ private Long productId;
+
+ private int quantity;
+
+ private BigDecimal total;
+
+ public OrderDto() {
+ }
+
+ public OrderDto(Long id, Long customerId, Long productId, int quantity, BigDecimal total) {
+ this.id = id;
+ this.customerId = customerId;
+ this.productId = productId;
+ this.quantity = quantity;
+ this.total = total;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Long getCustomerId() {
+ return customerId;
+ }
+
+ public void setCustomerId(Long customerId) {
+ this.customerId = customerId;
+ }
+
+ public Long getProductId() {
+ return productId;
+ }
+
+ public void setProductId(Long productId) {
+ this.productId = productId;
+ }
+
+ public int getQuantity() {
+ return quantity;
+ }
+
+ public void setQuantity(int quantity) {
+ this.quantity = quantity;
+ }
+
+ public BigDecimal getTotal() {
+ return total;
+ }
+
+ public void setTotal(BigDecimal total) {
+ this.total = total;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ OrderDto that = (OrderDto) o;
+ return quantity == that.quantity && Objects.equals(id, that.id) && Objects.equals(customerId, that.customerId)
+ && Objects.equals(productId, that.productId) && Objects.equals(total, that.total);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, customerId, productId, quantity, total);
+ }
+
+ @Override
+ public String toString() {
+ return "OrderDto{" +
+ "id=" + id +
+ ", customerId=" + customerId +
+ ", productId=" + productId +
+ ", quantity=" + quantity +
+ ", total=" + total +
+ "}";
+ }
+}
diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/entity/Order.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/entity/Order.java
new file mode 100644
index 0000000..e81c9ef
--- /dev/null
+++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/entity/Order.java
@@ -0,0 +1,116 @@
+package com.edgareldy.springjdbctutorial.core.order.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+import java.math.BigDecimal;
+import java.util.Objects;
+
+/**
+ * Row of the orders table. Both references are plain ids: the foreign keys are enforced by the database only.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+@Table(name = "orders")
+public class Order {
+
+ @Id
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "customer_id")
+ private Long customerId;
+
+ @Column(name = "product_id")
+ private Long productId;
+
+ @Column(name = "quantity")
+ private int quantity;
+
+ @Column(name = "total")
+ private BigDecimal total;
+
+ public Order() {
+ }
+
+ public Order(Long id, Long customerId, Long productId, int quantity, BigDecimal total) {
+ this.id = id;
+ this.customerId = customerId;
+ this.productId = productId;
+ this.quantity = quantity;
+ this.total = total;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Long getCustomerId() {
+ return customerId;
+ }
+
+ public void setCustomerId(Long customerId) {
+ this.customerId = customerId;
+ }
+
+ public Long getProductId() {
+ return productId;
+ }
+
+ public void setProductId(Long productId) {
+ this.productId = productId;
+ }
+
+ public int getQuantity() {
+ return quantity;
+ }
+
+ public void setQuantity(int quantity) {
+ this.quantity = quantity;
+ }
+
+ public BigDecimal getTotal() {
+ return total;
+ }
+
+ public void setTotal(BigDecimal total) {
+ this.total = total;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Order that = (Order) o;
+ return quantity == that.quantity && Objects.equals(id, that.id) && Objects.equals(customerId, that.customerId)
+ && Objects.equals(productId, that.productId) && Objects.equals(total, that.total);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, customerId, productId, quantity, total);
+ }
+
+ @Override
+ public String toString() {
+ return "Order{" +
+ "id=" + id +
+ ", customerId=" + customerId +
+ ", productId=" + productId +
+ ", quantity=" + quantity +
+ ", total=" + total +
+ "}";
+ }
+}
diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapper.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapper.java
new file mode 100644
index 0000000..899a9bf
--- /dev/null
+++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapper.java
@@ -0,0 +1,31 @@
+package com.edgareldy.springjdbctutorial.core.order.mapper;
+
+import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto;
+import com.edgareldy.springjdbctutorial.core.order.entity.Order;
+
+/**
+ * Converts between the Order entity and the OrderDto.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class OrderMapper {
+
+ public OrderDto toDto(Order order) {
+ if (order == null) {
+ return null;
+ }
+ return new OrderDto(order.getId(), order.getCustomerId(), order.getProductId(),
+ order.getQuantity(), order.getTotal());
+ }
+
+ public Order toEntity(OrderDto dto) {
+ if (dto == null) {
+ return null;
+ }
+ return new Order(dto.getId(), dto.getCustomerId(), dto.getProductId(),
+ dto.getQuantity(), dto.getTotal());
+ }
+}
diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapper.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapper.java
new file mode 100644
index 0000000..f30cc7d
--- /dev/null
+++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapper.java
@@ -0,0 +1,28 @@
+package com.edgareldy.springjdbctutorial.core.order.mapper;
+
+import com.edgareldy.springjdbctutorial.core.order.entity.Order;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+/**
+ * Converts one row of the orders table into an Order entity. Every column is NOT NULL.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class OrderRowMapper implements RowMapper {
+
+ @Override
+ public Order mapRow(ResultSet rs, int rowNum) throws SQLException {
+ return new Order(
+ rs.getLong("id"),
+ rs.getLong("customer_id"),
+ rs.getLong("product_id"),
+ rs.getInt("quantity"),
+ rs.getBigDecimal("total"));
+ }
+}
diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/OrderService.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/OrderService.java
new file mode 100644
index 0000000..d940766
--- /dev/null
+++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/OrderService.java
@@ -0,0 +1,24 @@
+package com.edgareldy.springjdbctutorial.core.order.service;
+
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto;
+
+/**
+ * Business operations on orders. Dto types only at this boundary. Orders are immutable.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public interface OrderService {
+
+ /** One page of orders, optionally restricted to a customer and/or a product (filters may be null). */
+ PageDto list(int page, int size, Long customerId, Long productId);
+
+ /** Throws ResourceNotFoundException when the id is unknown. */
+ OrderDto get(Long id);
+
+ /** The customer and the product must exist. The total is computed as unit price times quantity. */
+ OrderDto create(OrderDto input);
+}
diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java
new file mode 100644
index 0000000..e13eea9
--- /dev/null
+++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java
@@ -0,0 +1,107 @@
+package com.edgareldy.springjdbctutorial.core.order.service.impl;
+
+import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto;
+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 com.edgareldy.springjdbctutorial.core.customer.service.CustomerService;
+import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao;
+import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto;
+import com.edgareldy.springjdbctutorial.core.order.entity.Order;
+import com.edgareldy.springjdbctutorial.core.order.mapper.OrderMapper;
+import com.edgareldy.springjdbctutorial.core.order.service.OrderService;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implementation of OrderService. 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 OrderServiceImpl implements OrderService {
+
+ private static final int MAX_PAGE_SIZE = 100;
+ private static final int MIN_QUANTITY = 1;
+ private static final int MAX_QUANTITY = 1_000_000;
+ /** Largest value of the NUMERIC(14,2) total column. */
+ private static final BigDecimal MAX_TOTAL = new BigDecimal("999999999999.99");
+
+ private final OrderDao orderDao;
+ private final ProductService productService;
+ private final CustomerService customerService;
+ private final OrderMapper mapper = new OrderMapper();
+
+ public OrderServiceImpl(OrderDao orderDao, ProductService productService, CustomerService customerService) {
+ this.orderDao = orderDao;
+ this.productService = productService;
+ this.customerService = customerService;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public PageDto list(int page, int size, Long customerId, Long productId) {
+ 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 (Order order : orderDao.findPage(page, size, customerId, productId)) {
+ content.add(mapper.toDto(order));
+ }
+ return new PageDto<>(content, page, size, orderDao.count(customerId, productId));
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public OrderDto get(Long id) {
+ return mapper.toDto(orderDao.findById(id)
+ .orElseThrow(() -> new ResourceNotFoundException("Order not found: " + id)));
+ }
+
+ @Override
+ @Transactional
+ public OrderDto create(OrderDto input) {
+ if (input.getCustomerId() == null) {
+ throw new BusinessRuleException("Customer id must not be null");
+ }
+ if (input.getProductId() == null) {
+ throw new BusinessRuleException("Product id must not be null");
+ }
+ int quantity = input.getQuantity();
+ if (quantity < MIN_QUANTITY || quantity > MAX_QUANTITY) {
+ throw new BusinessRuleException("Quantity must be between " + MIN_QUANTITY + " and " + MAX_QUANTITY);
+ }
+ // Cross-module dependency: the order module reaches customers and products only through their
+ // SERVICE interfaces (never their DAOs). The beans are the transactional proxies, so these calls
+ // join this transaction, and their ResourceNotFoundException propagates as a 404.
+ customerService.get(input.getCustomerId());
+ ProductDto product = productService.get(input.getProductId());
+
+ // The exact product is compared with the column limit before any scaling
+ BigDecimal exact = product.getUnitPrice().multiply(BigDecimal.valueOf(quantity));
+ if (exact.compareTo(MAX_TOTAL) > 0) {
+ throw new BusinessRuleException("Order total must not exceed " + MAX_TOTAL.toPlainString());
+ }
+ // MAX_TOTAL has two decimals, so rounding a value at or below it to two decimals cannot exceed it
+ BigDecimal total = exact.setScale(2, RoundingMode.HALF_UP);
+
+ Order order = new Order(null, input.getCustomerId(), input.getProductId(), quantity, total);
+ try {
+ return mapper.toDto(orderDao.insert(order));
+ } catch (DataIntegrityViolationException e) {
+ // The customer or the product was deleted between the checks and the insert (foreign key)
+ throw new ResourceNotFoundException("Customer or product no longer exists");
+ }
+ }
+}
diff --git a/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImplTest.java b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImplTest.java
new file mode 100644
index 0000000..74737d3
--- /dev/null
+++ b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImplTest.java
@@ -0,0 +1,148 @@
+package com.edgareldy.springjdbctutorial.core.order.dao.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig;
+import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer;
+import com.edgareldy.springjdbctutorial.core.order.config.DaoConfig;
+import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao;
+import com.edgareldy.springjdbctutorial.core.order.entity.Order;
+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 OrderDaoImpl against the real PostgreSQL, with its own fixture file order-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("/order-dao-dataset.sql")
+class OrderDaoImplTest {
+
+ @DynamicPropertySource
+ static void datasource(DynamicPropertyRegistry registry) {
+ PostgresTestContainer.register(registry);
+ }
+
+ @Autowired
+ private OrderDao orderDao;
+
+ @Test
+ void _01_ShouldReturnFirstPageOrderedById_WhenNoFilterIsGiven() {
+ assertThat(orderDao.findPage(0, 3, null, null)).extracting(Order::getId).containsExactly(1L, 2L, 3L);
+ }
+
+ @Test
+ void _02_ShouldApplyOffsetOfPageTimesSize_WhenPageIsOne() {
+ assertThat(orderDao.findPage(1, 3, null, null)).extracting(Order::getId).containsExactly(4L, 5L);
+ }
+
+ @Test
+ void _03_ShouldReturnEmptyList_WhenPageIsBeyondTheLastOne() {
+ assertThat(orderDao.findPage(50, 10, null, null)).isEmpty();
+ }
+
+ @Test
+ void _04_ShouldReturnOnlyThatCustomersOrders_WhenFilteringByCustomerId() {
+ assertThat(orderDao.findPage(0, 10, 2L, null)).extracting(Order::getId).containsExactly(3L, 4L);
+ }
+
+ @Test
+ void _05_ShouldReturnOnlyThatProductsOrders_WhenFilteringByProductId() {
+ assertThat(orderDao.findPage(0, 10, null, 2L)).extracting(Order::getId).containsExactly(2L, 4L);
+ }
+
+ @Test
+ void _06_ShouldCombineBothFilters_WhenCustomerIdAndProductIdAreGiven() {
+ assertThat(orderDao.findPage(0, 10, 1L, 1L)).extracting(Order::getId).containsExactly(1L, 5L);
+ }
+
+ @Test
+ void _07_ShouldPaginateInsideTheFilter_WhenFilterAndPageAreCombined() {
+ assertThat(orderDao.findPage(1, 2, 1L, null)).extracting(Order::getId).containsExactly(5L);
+ }
+
+ @Test
+ void _08_ShouldReturnEmptyList_WhenNoOrderMatchesTheFilters() {
+ assertThat(orderDao.findPage(0, 10, 999L, null)).isEmpty();
+ assertThat(orderDao.findPage(0, 10, null, 999L)).isEmpty();
+ assertThat(orderDao.findPage(0, 10, 2L, 999L)).isEmpty();
+ }
+
+ @Test
+ void _09_ShouldCountWithTheSameFilters_WhenCounting() {
+ assertThat(orderDao.count(null, null)).isEqualTo(5L);
+ assertThat(orderDao.count(1L, null)).isEqualTo(3L);
+ assertThat(orderDao.count(null, 2L)).isEqualTo(2L);
+ assertThat(orderDao.count(1L, 1L)).isEqualTo(2L);
+ assertThat(orderDao.count(2L, 999L)).isZero();
+ }
+
+ @Test
+ void _10_ShouldReturnEveryColumn_WhenIdExists() {
+ Order order = orderDao.findById(2L).orElseThrow();
+
+ assertThat(order).isEqualTo(new Order(2L, 1L, 2L, 3, new BigDecimal("59.97")));
+ }
+
+ @Test
+ void _11_ShouldReturnEmpty_WhenIdDoesNotExist() {
+ assertThat(orderDao.findById(999L)).isEmpty();
+ }
+
+ @Test
+ void _12_ShouldGenerateIdAndKeepTheExactTotal_WhenOrderIsInserted() {
+ Order created = orderDao.insert(new Order(null, 2L, 2L, 7, new BigDecimal("139.93")));
+
+ assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L);
+ Order read = orderDao.findById(created.getId()).orElseThrow();
+ assertThat(read).isEqualTo(created);
+ assertThat(read.getTotal()).isEqualByComparingTo("139.93");
+ assertThat(orderDao.count(null, null)).isEqualTo(6L);
+ }
+
+ @Test
+ void _13_ShouldRoundTripTheLargestTotal_WhenTotalIsTheColumnMaximum() {
+ Order created = orderDao.insert(new Order(null, 1L, 1L, 1, new BigDecimal("999999999999.99")));
+
+ assertThat(orderDao.findById(created.getId()).orElseThrow().getTotal()).isEqualByComparingTo("999999999999.99");
+ }
+
+ @Test
+ void _14_ShouldThrowDataIntegrityViolationException_WhenCustomerDoesNotExist() {
+ assertThatThrownBy(() -> orderDao.insert(new Order(null, 999L, 1L, 1, new BigDecimal("10.00"))))
+ .isInstanceOf(DataIntegrityViolationException.class);
+
+ assertThat(orderDao.count(null, null)).isEqualTo(5L);
+ }
+
+ @Test
+ void _15_ShouldThrowDataIntegrityViolationException_WhenProductDoesNotExist() {
+ assertThatThrownBy(() -> orderDao.insert(new Order(null, 1L, 999L, 1, new BigDecimal("10.00"))))
+ .isInstanceOf(DataIntegrityViolationException.class);
+
+ assertThat(orderDao.count(null, null)).isEqualTo(5L);
+ }
+
+ @Test
+ void _16_ShouldThrowDataIntegrityViolationException_WhenQuantityIsNotPositive() {
+ assertThatThrownBy(() -> orderDao.insert(new Order(null, 1L, 1L, 0, new BigDecimal("0.00"))))
+ .isInstanceOf(DataIntegrityViolationException.class);
+
+ assertThat(orderDao.count(null, null)).isEqualTo(5L);
+ }
+}
diff --git a/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapperTest.java b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapperTest.java
new file mode 100644
index 0000000..43e6744
--- /dev/null
+++ b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapperTest.java
@@ -0,0 +1,41 @@
+package com.edgareldy.springjdbctutorial.core.order.mapper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto;
+import com.edgareldy.springjdbctutorial.core.order.entity.Order;
+import java.math.BigDecimal;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests OrderMapper: entity to dto and dto to entity, with hand-built objects and no mock.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+class OrderMapperTest {
+
+ private final OrderMapper mapper = new OrderMapper();
+
+ @Test
+ void _01_ShouldCopyEveryField_WhenEntityBecomesDto() {
+ OrderDto dto = mapper.toDto(new Order(1L, 2L, 3L, 4, new BigDecimal("79.96")));
+
+ assertThat(dto).isEqualTo(new OrderDto(1L, 2L, 3L, 4, new BigDecimal("79.96")));
+ }
+
+ @Test
+ void _02_ShouldCopyEveryField_WhenDtoBecomesEntity() {
+ Order order = mapper.toEntity(new OrderDto(null, 2L, 3L, 4, new BigDecimal("79.96")));
+
+ assertThat(order).isEqualTo(new Order(null, 2L, 3L, 4, new BigDecimal("79.96")));
+ }
+
+ @Test
+ void _03_ShouldReturnNull_WhenTheInputIsNull() {
+ assertThat(mapper.toDto(null)).isNull();
+ assertThat(mapper.toEntity(null)).isNull();
+ }
+}
diff --git a/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapperTest.java b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapperTest.java
new file mode 100644
index 0000000..ba66040
--- /dev/null
+++ b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapperTest.java
@@ -0,0 +1,55 @@
+package com.edgareldy.springjdbctutorial.core.order.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.order.entity.Order;
+import java.math.BigDecimal;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests OrderRowMapper 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 OrderRowMapperTest {
+
+ private final OrderRowMapper mapper = new OrderRowMapper();
+
+ // 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.getLong("customer_id")).thenReturn(3L);
+ when(rs.getLong("product_id")).thenReturn(4L);
+ when(rs.getInt("quantity")).thenReturn(3);
+ when(rs.getBigDecimal("total")).thenReturn(new BigDecimal("59.97"));
+
+ Order order = mapper.mapRow(rs, 0);
+
+ assertThat(order).isEqualTo(new Order(7L, 3L, 4L, 3, new BigDecimal("59.97")));
+ }
+
+ @Test
+ void _02_ShouldKeepTheExactDecimalScale_WhenTotalIsZero() throws SQLException {
+ ResultSet rs = mock(ResultSet.class);
+ when(rs.getLong("id")).thenReturn(8L);
+ when(rs.getLong("customer_id")).thenReturn(1L);
+ when(rs.getLong("product_id")).thenReturn(1L);
+ when(rs.getInt("quantity")).thenReturn(1);
+ when(rs.getBigDecimal("total")).thenReturn(new BigDecimal("0.00"));
+
+ Order order = mapper.mapRow(rs, 0);
+
+ assertThat(order.getTotal()).isEqualTo(new BigDecimal("0.00"));
+ assertThat(order.getQuantity()).isEqualTo(1);
+ }
+}
diff --git a/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImplTest.java b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImplTest.java
new file mode 100644
index 0000000..162b8a9
--- /dev/null
+++ b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImplTest.java
@@ -0,0 +1,287 @@
+package com.edgareldy.springjdbctutorial.core.order.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.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.dto.ProductDto;
+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 com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto;
+import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService;
+import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao;
+import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto;
+import com.edgareldy.springjdbctutorial.core.order.entity.Order;
+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.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DataIntegrityViolationException;
+
+/**
+ * Unit tests of OrderServiceImpl: the DAO and the customer and product SERVICES are Mockito mocks (the order module only sees service interfaces), 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 OrderServiceImplTest {
+
+ @Mock
+ private OrderDao orderDao;
+ @Mock
+ private ProductService productService;
+ @Mock
+ private CustomerService customerService;
+
+ private OrderServiceImpl service() {
+ return new OrderServiceImpl(orderDao, productService, customerService);
+ }
+
+ private static OrderDto input(Long customerId, Long productId, int quantity) {
+ return new OrderDto(null, customerId, productId, quantity, null);
+ }
+
+ private void productAt(String unitPrice) {
+ when(productService.get(2L)).thenReturn(new ProductDto(2L, 1L, "Atlas", new BigDecimal(unitPrice)));
+ }
+
+ private void insertEchoesWithId() {
+ when(orderDao.insert(any(Order.class))).thenAnswer(call -> {
+ Order order = call.getArgument(0);
+ order.setId(50L);
+ return order;
+ });
+ }
+
+ private Order inserted() {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Order.class);
+ verify(orderDao).insert(captor.capture());
+ return captor.getValue();
+ }
+
+ // ---------------------------------------------------------------- create: total
+
+ @Test
+ void _01_ShouldComputeTheExactTotal_WhenQuantityIsThreeAndPriceIs1999() {
+ when(customerService.get(1L)).thenReturn(new CustomerDto(1L, "Alice", "Martin", null, null, null));
+ productAt("19.99");
+ insertEchoesWithId();
+
+ OrderDto created = service().create(input(1L, 2L, 3));
+
+ assertThat(created).isEqualTo(new OrderDto(50L, 1L, 2L, 3, new BigDecimal("59.97")));
+ assertThat(inserted().getTotal()).isEqualTo(new BigDecimal("59.97"));
+ }
+
+ @Test
+ void _02_ShouldStoreTotalZeroWithTwoDecimals_WhenProductIsFree() {
+ productAt("0.00");
+ insertEchoesWithId();
+
+ service().create(input(1L, 2L, 1));
+
+ assertThat(inserted().getTotal()).isEqualTo(new BigDecimal("0.00"));
+ }
+
+ @Test
+ void _03_ShouldAcceptTheLargestTotal_WhenMillionUnitsAtTheHighestSafePrice() {
+ productAt("999999.99");
+ insertEchoesWithId();
+
+ service().create(input(1L, 2L, 1_000_000));
+
+ assertThat(inserted().getTotal()).isEqualTo(new BigDecimal("999999990000.00"));
+ }
+
+ @Test
+ void _04_ShouldRefuseAndInsertNothing_WhenTotalExceedsTheColumnLimit() {
+ productAt("1000000.00");
+
+ assertThatThrownBy(() -> service().create(input(1L, 2L, 1_000_000)))
+ .isInstanceOf(BusinessRuleException.class)
+ .hasMessage("Order total must not exceed 999999999999.99");
+
+ verify(orderDao, never()).insert(any());
+ }
+
+ @Test
+ void _05_ShouldRefuseAndInsertNothing_WhenTotalIsJustAboveTheLimit() {
+ productAt("1000000000000.00");
+
+ assertThatThrownBy(() -> service().create(input(1L, 2L, 1))).isInstanceOf(BusinessRuleException.class);
+
+ verify(orderDao, never()).insert(any());
+ }
+
+ @Test
+ void _06_ShouldRoundHalfUpToTwoDecimals_WhenProductPriceHasMoreDecimals() {
+ productAt("0.005");
+ insertEchoesWithId();
+
+ service().create(input(1L, 2L, 1));
+
+ assertThat(inserted().getTotal()).isEqualTo(new BigDecimal("0.01"));
+ }
+
+ // ---------------------------------------------------------------- create: quantity and ids
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, -1, 1_000_001})
+ void _07_ShouldRefuseAndTouchNothing_WhenQuantityIsOutOfRange(int quantity) {
+ assertThatThrownBy(() -> service().create(input(1L, 2L, quantity)))
+ .isInstanceOf(BusinessRuleException.class)
+ .hasMessage("Quantity must be between 1 and 1000000");
+
+ verifyNoInteractions(orderDao, productService, customerService);
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {1, 1_000_000})
+ void _08_ShouldAcceptTheBoundary_WhenQuantityIsOneOrAMillion(int quantity) {
+ productAt("0.50");
+ insertEchoesWithId();
+
+ service().create(input(1L, 2L, quantity));
+
+ assertThat(inserted().getQuantity()).isEqualTo(quantity);
+ }
+
+ @Test
+ void _09_ShouldRefuseAndTouchNothing_WhenCustomerIdIsNull() {
+ assertThatThrownBy(() -> service().create(input(null, 2L, 1)))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("Customer id must not be null");
+
+ verifyNoInteractions(orderDao, productService, customerService);
+ }
+
+ @Test
+ void _10_ShouldRefuseAndTouchNothing_WhenProductIdIsNull() {
+ assertThatThrownBy(() -> service().create(input(1L, null, 1)))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("Product id must not be null");
+
+ verifyNoInteractions(orderDao, productService, customerService);
+ }
+
+ // ---------------------------------------------------------------- create: other modules
+
+ @Test
+ void _11_ShouldPropagateNotFoundAndNotCallTheProductService_WhenCustomerIsUnknown() {
+ when(customerService.get(9L)).thenThrow(new ResourceNotFoundException("Customer not found: 9"));
+
+ assertThatThrownBy(() -> service().create(input(9L, 2L, 1)))
+ .isInstanceOf(ResourceNotFoundException.class).hasMessage("Customer not found: 9");
+
+ verifyNoInteractions(productService, orderDao);
+ }
+
+ @Test
+ void _12_ShouldPropagateNotFoundAndInsertNothing_WhenProductIsUnknown() {
+ when(customerService.get(1L)).thenReturn(new CustomerDto(1L, "Alice", "Martin", null, null, null));
+ when(productService.get(9L)).thenThrow(new ResourceNotFoundException("Product not found: 9"));
+
+ assertThatThrownBy(() -> service().create(input(1L, 9L, 1)))
+ .isInstanceOf(ResourceNotFoundException.class).hasMessage("Product not found: 9");
+
+ verifyNoInteractions(orderDao);
+ }
+
+ @Test
+ void _13_ShouldTurnIntoNotFound_WhenTheForeignKeyFailsAtInsert() {
+ productAt("10.00");
+ when(orderDao.insert(any(Order.class))).thenThrow(new DataIntegrityViolationException("fk"));
+
+ assertThatThrownBy(() -> service().create(input(1L, 2L, 1)))
+ .isInstanceOf(ResourceNotFoundException.class)
+ .hasMessage("Customer or product no longer exists");
+ }
+
+ // ---------------------------------------------------------------- list and get
+
+ @Test
+ void _14_ShouldReturnDtosAndPassFiltersToTheDao_WhenListingWithFilters() {
+ when(orderDao.findPage(1, 5, 3L, 4L)).thenReturn(List.of(new Order(9L, 3L, 4L, 2, new BigDecimal("5.00"))));
+ when(orderDao.count(3L, 4L)).thenReturn(6L);
+
+ PageDto page = service().list(1, 5, 3L, 4L);
+
+ assertThat(page.getContent()).containsExactly(new OrderDto(9L, 3L, 4L, 2, new BigDecimal("5.00")));
+ assertThat(page.getPage()).isEqualTo(1);
+ assertThat(page.getSize()).isEqualTo(5);
+ assertThat(page.getTotalElements()).isEqualTo(6L);
+ }
+
+ @Test
+ void _15_ShouldPassNullFiltersToTheDao_WhenListingWithoutFilters() {
+ when(orderDao.findPage(0, 20, null, null)).thenReturn(List.of());
+ when(orderDao.count(null, null)).thenReturn(0L);
+
+ assertThat(service().list(0, 20, null, null).getContent()).isEmpty();
+ }
+
+ @Test
+ void _16_ShouldKeepEmptyContentAndTotal_WhenPageIsBeyondTheLastOne() {
+ when(orderDao.findPage(99, 10, null, null)).thenReturn(List.of());
+ when(orderDao.count(null, null)).thenReturn(3L);
+
+ PageDto page = service().list(99, 10, null, null);
+
+ assertThat(page.getContent()).isEmpty();
+ assertThat(page.getTotalElements()).isEqualTo(3L);
+ }
+
+ @Test
+ void _17_ShouldRefuseAndNotQuery_WhenPageIsNegative() {
+ assertThatThrownBy(() -> service().list(-1, 10, null, null)).isInstanceOf(BusinessRuleException.class);
+
+ verifyNoInteractions(orderDao);
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, -1, 101})
+ void _18_ShouldRefuseAndNotQuery_WhenSizeIsOutOfBounds(int size) {
+ assertThatThrownBy(() -> service().list(0, size, null, null)).isInstanceOf(BusinessRuleException.class);
+
+ verifyNoInteractions(orderDao);
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {1, 100})
+ void _19_ShouldAcceptTheBoundarySizes_WhenSizeIsOneOrOneHundred(int size) {
+ when(orderDao.findPage(0, size, null, null)).thenReturn(List.of());
+ when(orderDao.count(null, null)).thenReturn(0L);
+
+ assertThat(service().list(0, size, null, null).getSize()).isEqualTo(size);
+ }
+
+ @Test
+ void _20_ShouldReturnTheDto_WhenOrderExists() {
+ when(orderDao.findById(9L)).thenReturn(Optional.of(new Order(9L, 3L, 4L, 2, new BigDecimal("5.00"))));
+
+ assertThat(service().get(9L)).isEqualTo(new OrderDto(9L, 3L, 4L, 2, new BigDecimal("5.00")));
+ }
+
+ @Test
+ void _21_ShouldThrowNotFound_WhenOrderDoesNotExist() {
+ when(orderDao.findById(99L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service().get(99L))
+ .isInstanceOf(ResourceNotFoundException.class).hasMessage("Order not found: 99");
+ }
+}
diff --git a/core/order/src/test/resources/order-dao-dataset.sql b/core/order/src/test/resources/order-dao-dataset.sql
new file mode 100644
index 0000000..2526ca0
--- /dev/null
+++ b/core/order/src/test/resources/order-dao-dataset.sql
@@ -0,0 +1,22 @@
+-- Fixture of OrderDaoImplTest only. Wipes the order, catalog and customer tables, then loads two customers (1, 2),
+-- one category, two products (1 at 10.00, 2 at 19.99) and five orders: 1 (customer 1, product 1), 2 (customer 1,
+-- product 2), 3 (customer 2, product 1), 4 (customer 2, product 2), 5 (customer 1, product 1).
+TRUNCATE TABLE orders, products, categories, customers RESTART IDENTITY CASCADE;
+
+INSERT INTO customers (id, first_name, last_name) VALUES (1, 'Alice', 'Martin'), (2, 'Bob', 'Stone');
+
+INSERT INTO categories (id, category_name) VALUES (1, 'Books');
+INSERT INTO products (id, category_id, product_name, unit_price) VALUES (1, 1, 'Novel', 10.00), (2, 1, 'Atlas', 19.99);
+
+INSERT INTO orders (id, customer_id, product_id, quantity, total) VALUES
+ (1, 1, 1, 2, 20.00),
+ (2, 1, 2, 3, 59.97),
+ (3, 2, 1, 1, 10.00),
+ (4, 2, 2, 5, 99.95),
+ (5, 1, 1, 4, 40.00);
+
+-- Explicit ids do not advance the sequences: move them high so generated ids never collide
+ALTER SEQUENCE customers_id_seq RESTART WITH 100;
+ALTER SEQUENCE categories_id_seq RESTART WITH 100;
+ALTER SEQUENCE products_id_seq RESTART WITH 100;
+ALTER SEQUENCE orders_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 0657ea5..2f3ecc7 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
@@ -1,5 +1,6 @@
package com.edgareldy.springjdbctutorial.ws.config;
+import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.edgareldy.springjdbctutorial.core.common.config.CommonConfig;
@@ -39,7 +40,8 @@
@EnableWebMvc
@Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class,
com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class,
- com.edgareldy.springjdbctutorial.core.customer.config.ServiceConfig.class, SecurityConfig.class})
+ com.edgareldy.springjdbctutorial.core.customer.config.ServiceConfig.class,
+ com.edgareldy.springjdbctutorial.core.order.config.ServiceConfig.class, SecurityConfig.class})
@ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"})
public class WebMvcConfig implements WebMvcConfigurer {
@@ -50,7 +52,10 @@ public class WebMvcConfig implements WebMvcConfigurer {
@Bean
public ObjectMapper objectMapper() {
return Jackson2ObjectMapperBuilder.json()
- .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
+ .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS,
+ // Jackson would silently truncate 1.5 to 1 for an int field, i.e. order one unit
+ // instead of refusing the body: a decimal is never a valid integer here
+ DeserializationFeature.ACCEPT_FLOAT_AS_INT)
.build();
}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/OrderController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/OrderController.java
new file mode 100644
index 0000000..9756abe
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/OrderController.java
@@ -0,0 +1,64 @@
+package com.edgareldy.springjdbctutorial.ws.controller;
+
+import com.edgareldy.springjdbctutorial.core.order.service.OrderService;
+import com.edgareldy.springjdbctutorial.ws.converter.OrderConverter;
+import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse;
+import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse;
+import com.edgareldy.springjdbctutorial.ws.payload.order.OrderRequest;
+import com.edgareldy.springjdbctutorial.ws.payload.order.OrderResponse;
+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.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+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 read and place orders. 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/orders")
+public class OrderController {
+
+ private final OrderService orderService;
+
+ public OrderController(OrderService orderService) {
+ this.orderService = orderService;
+ }
+
+ @GetMapping
+ @PreAuthorize("hasPermission('ORDER','READ')")
+ public ApiResponse> list(@RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
+ @RequestParam(required = false) Long customerId,
+ @RequestParam(required = false) Long productId) {
+ return ApiResponse.success(
+ OrderConverter.toPageResponse(orderService.list(page, size, customerId, productId)), "Orders");
+ }
+
+ @GetMapping("/{id}")
+ @PreAuthorize("hasPermission('ORDER','READ')")
+ public ApiResponse get(@PathVariable Long id) {
+ return ApiResponse.success(OrderConverter.toResponse(orderService.get(id)), "Order");
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ @PreAuthorize("hasPermission('ORDER','WRITE')")
+ public ApiResponse create(@Valid @RequestBody OrderRequest request) {
+ return ApiResponse.success(OrderConverter.toResponse(orderService.create(OrderConverter.toDto(request))),
+ "Order created");
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/OrderConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/OrderConverter.java
new file mode 100644
index 0000000..6e1d4d6
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/OrderConverter.java
@@ -0,0 +1,45 @@
+package com.edgareldy.springjdbctutorial.ws.converter;
+
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto;
+import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse;
+import com.edgareldy.springjdbctutorial.ws.payload.order.OrderRequest;
+import com.edgareldy.springjdbctutorial.ws.payload.order.OrderResponse;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Converts between the core OrderDto and the order HTTP payloads.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public final class OrderConverter {
+
+ private OrderConverter() {
+ }
+
+ public static OrderDto toDto(OrderRequest request) {
+ OrderDto dto = new OrderDto();
+ dto.setCustomerId(request.getCustomerId());
+ dto.setProductId(request.getProductId());
+ dto.setQuantity(request.getQuantity());
+ return dto;
+ }
+
+ public static OrderResponse toResponse(OrderDto dto) {
+ return new OrderResponse(dto.getId(), dto.getCustomerId(), dto.getProductId(),
+ dto.getQuantity(), dto.getTotal());
+ }
+
+ public static PageResponse toPageResponse(PageDto page) {
+ List content = new ArrayList<>();
+ for (OrderDto 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/order/OrderRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderRequest.java
new file mode 100644
index 0000000..858a46e
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderRequest.java
@@ -0,0 +1,83 @@
+package com.edgareldy.springjdbctutorial.ws.payload.order;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.Objects;
+
+/**
+ * Body of the create order call. The total is never sent, the server computes it.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class OrderRequest {
+
+ @NotNull
+ private Long customerId;
+
+ @NotNull
+ private Long productId;
+
+ @NotNull
+ @Min(1)
+ @Max(1000000)
+ private Integer quantity;
+
+ public OrderRequest() {
+ }
+
+ public Long getCustomerId() {
+ return customerId;
+ }
+
+ public void setCustomerId(Long customerId) {
+ this.customerId = customerId;
+ }
+
+ public Long getProductId() {
+ return productId;
+ }
+
+ public void setProductId(Long productId) {
+ this.productId = productId;
+ }
+
+ public Integer getQuantity() {
+ return quantity;
+ }
+
+ public void setQuantity(Integer quantity) {
+ this.quantity = quantity;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ OrderRequest that = (OrderRequest) o;
+ return Objects.equals(customerId, that.customerId) && Objects.equals(productId, that.productId)
+ && Objects.equals(quantity, that.quantity);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(customerId, productId, quantity);
+ }
+
+ @Override
+ public String toString() {
+ return "OrderRequest{" +
+ "customerId=" + customerId +
+ ", productId=" + productId +
+ ", quantity=" + quantity +
+ "}";
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderResponse.java
new file mode 100644
index 0000000..a122289
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderResponse.java
@@ -0,0 +1,105 @@
+package com.edgareldy.springjdbctutorial.ws.payload.order;
+
+import java.math.BigDecimal;
+import java.util.Objects;
+
+/**
+ * An order 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 OrderResponse {
+
+ private Long id;
+
+ private Long customerId;
+
+ private Long productId;
+
+ private int quantity;
+
+ private BigDecimal total;
+
+ public OrderResponse() {
+ }
+
+ public OrderResponse(Long id, Long customerId, Long productId, int quantity, BigDecimal total) {
+ this.id = id;
+ this.customerId = customerId;
+ this.productId = productId;
+ this.quantity = quantity;
+ this.total = total;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Long getCustomerId() {
+ return customerId;
+ }
+
+ public void setCustomerId(Long customerId) {
+ this.customerId = customerId;
+ }
+
+ public Long getProductId() {
+ return productId;
+ }
+
+ public void setProductId(Long productId) {
+ this.productId = productId;
+ }
+
+ public int getQuantity() {
+ return quantity;
+ }
+
+ public void setQuantity(int quantity) {
+ this.quantity = quantity;
+ }
+
+ public BigDecimal getTotal() {
+ return total;
+ }
+
+ public void setTotal(BigDecimal total) {
+ this.total = total;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ OrderResponse that = (OrderResponse) o;
+ return quantity == that.quantity && Objects.equals(id, that.id) && Objects.equals(customerId, that.customerId)
+ && Objects.equals(productId, that.productId) && Objects.equals(total, that.total);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, customerId, productId, quantity, total);
+ }
+
+ @Override
+ public String toString() {
+ return "OrderResponse{" +
+ "id=" + id +
+ ", customerId=" + customerId +
+ ", productId=" + productId +
+ ", quantity=" + quantity +
+ ", total=" + total +
+ "}";
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/OrderFlowIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/OrderFlowIntegrationTest.java
new file mode 100644
index 0000000..a63ed35
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/OrderFlowIntegrationTest.java
@@ -0,0 +1,368 @@
+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.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 order flows through the REAL stack (real WebMvcConfig, real services, real PostgreSQL migrated by
+ * Flyway, real logins): the total computed from the product price at creation, the filters and pagination, the
+ * 404 and 422 refusals, the deletes of a customer or a product refused while an order references them, and the
+ * permission enforcement.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+// Same recipe as CustomerFlowIntegrationTest: the whole application context on the shared Testcontainers database.
+// Customers, categories and products are created through their own REAL endpoints with an admin, named with an
+// it- prefix; tearDown() removes the orders first (they reference the others), then the rest, then the
+// users and roles of RbacTestSupport.
+@SpringJUnitWebConfig(WebMvcConfig.class)
+class OrderFlowIntegrationTest {
+
+ private static final String PREFIX = "it-";
+ private static final String URL = "/api/v1/orders";
+
+ @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() {
+ jdbcTemplate.update("DELETE FROM orders WHERE customer_id IN (SELECT id FROM customers WHERE first_name LIKE ?) "
+ + "OR product_id IN (SELECT id FROM products WHERE product_name LIKE ?)", PREFIX + "%", PREFIX + "%");
+ jdbcTemplate.update("DELETE FROM customers WHERE first_name LIKE ?", PREFIX + "%");
+ 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 long idOf(MvcResult result) throws Exception {
+ return assertSuccess(result, 201).get("data").get("id").asLong();
+ }
+
+ private long customer(String jwt) throws Exception {
+ return idOf(support.post(jwt, "/api/v1/customers", "{\"firstName\":\"" + unique() + "\",\"lastName\":\"Martin\"}"));
+ }
+
+ private long category(String jwt) throws Exception {
+ return idOf(support.post(jwt, "/api/v1/categories", "{\"categoryName\":\"" + unique() + "\"}"));
+ }
+
+ private long product(String jwt, long categoryId, String unitPrice) throws Exception {
+ return idOf(support.post(jwt, "/api/v1/products", "{\"categoryId\":" + categoryId + ",\"productName\":\""
+ + unique() + "\",\"unitPrice\":" + unitPrice + "}"));
+ }
+
+ private static String orderBody(Object customerId, Object productId, Object quantity) {
+ return "{\"customerId\":" + customerId + ",\"productId\":" + productId + ",\"quantity\":" + quantity + "}";
+ }
+
+ private long order(String jwt, long customerId, long productId, int quantity) throws Exception {
+ return idOf(support.post(jwt, URL, orderBody(customerId, productId, quantity)));
+ }
+
+ private long count(String sql, Object... args) {
+ return jdbcTemplate.queryForObject(sql, Long.class, args);
+ }
+
+ private static List ids(JsonNode page) {
+ List ids = new ArrayList<>();
+ for (JsonNode order : page.get("data").get("content")) {
+ ids.add(order.get("id").asLong());
+ }
+ return ids;
+ }
+
+ @Test
+ void _01_ShouldComputeTheTotalFromTheProductPrice_WhenAdminCreatesAnOrder() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long customerId = customer(jwt);
+ long productId = product(jwt, category(jwt), "19.99");
+
+ JsonNode created = assertSuccess(support.post(jwt, URL, orderBody(customerId, productId, 3)), 201);
+
+ assertThat(created.get("message").asText()).isEqualTo("Order created");
+ assertThat(created.get("data").get("customerId").asLong()).isEqualTo(customerId);
+ assertThat(created.get("data").get("productId").asLong()).isEqualTo(productId);
+ assertThat(created.get("data").get("quantity").asInt()).isEqualTo(3);
+ assertThat(created.get("data").get("total").decimalValue()).isEqualByComparingTo("59.97");
+ long id = created.get("data").get("id").asLong();
+ JsonNode read = assertSuccess(support.get(jwt, URL + "/" + id), 200).get("data");
+ assertThat(read.get("total").decimalValue()).isEqualByComparingTo("59.97");
+ assertThat(read.get("quantity").asInt()).isEqualTo(3);
+ assertThat(count("SELECT COUNT(*) FROM orders WHERE id = ? AND total = 59.97", id)).isEqualTo(1L);
+ }
+
+ @Test
+ void _02_ShouldKeepTheTotal_WhenTheProductPriceIsUpdatedLater() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long customerId = customer(jwt);
+ long categoryId = category(jwt);
+ long productId = product(jwt, categoryId, "10.00");
+ long id = order(jwt, customerId, productId, 2);
+
+ assertSuccess(support.put(jwt, "/api/v1/products/" + productId,
+ "{\"categoryId\":" + categoryId + ",\"productName\":\"" + unique() + "\",\"unitPrice\":99.00}"), 200);
+
+ JsonNode read = assertSuccess(support.get(jwt, URL + "/" + id), 200).get("data");
+ assertThat(read.get("total").decimalValue()).isEqualByComparingTo("20.00");
+ long next = order(jwt, customerId, productId, 2);
+ assertThat(assertSuccess(support.get(jwt, URL + "/" + next), 200).get("data").get("total").decimalValue())
+ .isEqualByComparingTo("198.00");
+ }
+
+ @Test
+ void _03_ShouldFilterAndPaginate_WhenListingWithCustomerIdAndProductId() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long c1 = customer(jwt);
+ long c2 = customer(jwt);
+ long categoryId = category(jwt);
+ long p1 = product(jwt, categoryId, "1.00");
+ long p2 = product(jwt, categoryId, "2.00");
+ long o1 = order(jwt, c1, p1, 1);
+ long o2 = order(jwt, c1, p2, 1);
+ long o3 = order(jwt, c2, p1, 1);
+ long o4 = order(jwt, c1, p1, 2);
+
+ assertThat(ids(assertSuccess(support.get(jwt, URL + "?customerId=" + c1), 200))).containsExactly(o1, o2, o4);
+ assertThat(ids(assertSuccess(support.get(jwt, URL + "?productId=" + p1), 200))).containsExactly(o1, o3, o4);
+ assertThat(ids(assertSuccess(support.get(jwt, URL + "?customerId=" + c1 + "&productId=" + p1), 200)))
+ .containsExactly(o1, o4);
+ assertThat(ids(assertSuccess(support.get(jwt, URL + "?customerId=" + c2 + "&productId=" + p2), 200))).isEmpty();
+
+ JsonNode second = assertSuccess(support.get(jwt, URL + "?customerId=" + c1 + "&size=2&page=1"), 200);
+ assertThat(ids(second)).containsExactly(o4);
+ assertThat(second.get("data").get("totalElements").asLong()).isEqualTo(3L);
+ assertThat(second.get("data").get("totalPages").asInt()).isEqualTo(2);
+ }
+
+ @Test
+ void _04_ShouldReturn404AndStoreNothing_WhenCustomerOrProductDoesNotExist() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long customerId = customer(jwt);
+ long productId = product(jwt, category(jwt), "5.00");
+
+ JsonNode noCustomer = assertError(support.post(jwt, URL, orderBody(999999999L, productId, 1)), 404);
+ JsonNode noProduct = assertError(support.post(jwt, URL, orderBody(customerId, 999999999L, 1)), 404);
+
+ assertThat(noCustomer.get("message").asText()).isEqualTo("Customer not found: 999999999");
+ assertThat(noProduct.get("message").asText()).isEqualTo("Product not found: 999999999");
+ assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ? OR product_id = ?", customerId, productId))
+ .isZero();
+ }
+
+ @Test
+ void _05_ShouldReturn400AndStoreNothing_WhenBodyIsInvalid() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long customerId = customer(jwt);
+ long productId = product(jwt, category(jwt), "5.00");
+
+ for (Object quantity : List.of(0, -1, 1000001)) {
+ JsonNode json = assertError(support.post(jwt, URL, orderBody(customerId, productId, quantity)), 400);
+ assertThat(json.get("message").asText()).contains("quantity: ");
+ }
+ JsonNode missing = assertError(support.post(jwt, URL, "{\"customerId\":" + customerId + ",\"productId\":"
+ + productId + "}"), 400);
+ JsonNode noIds = assertError(support.post(jwt, URL, "{\"quantity\":1}"), 400);
+
+ assertThat(missing.get("message").asText()).contains("quantity: ");
+ assertThat(noIds.get("message").asText()).contains("customerId: ").contains("productId: ");
+ assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ?", customerId)).isZero();
+ }
+
+ @Test
+ void _06_ShouldReturn422AndStoreNothing_WhenTheTotalExceedsTheColumnLimit() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long customerId = customer(jwt);
+ long productId = product(jwt, category(jwt), "9999999999.99");
+
+ JsonNode json = assertError(support.post(jwt, URL, orderBody(customerId, productId, 1000000)), 422);
+
+ assertThat(json.get("message").asText()).isEqualTo("Order total must not exceed 999999999999.99");
+ assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ?", customerId)).isZero();
+ // 99 x 9999999999.99 is still below the limit and goes through
+ assertSuccess(support.post(jwt, URL, orderBody(customerId, productId, 99)), 201);
+ }
+
+ @Test
+ void _07_ShouldRefuseTheCustomerDeleteWith422UntilTheOrderIsRemoved_WhenAnOrderReferencesTheCustomer()
+ throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long customerId = customer(jwt);
+ long productId = product(jwt, category(jwt), "5.00");
+ long orderId = order(jwt, customerId, productId, 1);
+
+ JsonNode refused = assertError(support.delete(jwt, "/api/v1/customers/" + customerId), 422);
+
+ assertThat(refused.get("message").asText()).isEqualTo("Customer is referenced by orders");
+ assertSuccess(support.get(jwt, "/api/v1/customers/" + customerId), 200);
+ jdbcTemplate.update("DELETE FROM orders WHERE id = ?", orderId);
+ assertSuccess(support.delete(jwt, "/api/v1/customers/" + customerId), 200);
+ assertError(support.get(jwt, "/api/v1/customers/" + customerId), 404);
+ }
+
+ @Test
+ void _08_ShouldRefuseTheProductDeleteWith422UntilTheOrderIsRemoved_WhenAnOrderReferencesTheProduct()
+ throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long customerId = customer(jwt);
+ long productId = product(jwt, category(jwt), "5.00");
+ long orderId = order(jwt, customerId, productId, 1);
+
+ JsonNode refused = assertError(support.delete(jwt, "/api/v1/products/" + productId), 422);
+
+ assertThat(refused.get("message").asText()).isEqualTo("Product is referenced by orders");
+ assertSuccess(support.get(jwt, "/api/v1/products/" + productId), 200);
+ jdbcTemplate.update("DELETE FROM orders WHERE id = ?", orderId);
+ assertSuccess(support.delete(jwt, "/api/v1/products/" + productId), 200);
+ assertError(support.get(jwt, "/api/v1/products/" + productId), 404);
+ }
+
+ @Test
+ void _09_ShouldReturn404_WhenOrderDoesNotExist() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+
+ JsonNode json = assertError(support.get(jwt, URL + "/999999999"), 404);
+
+ assertThat(json.get("message").asText()).isEqualTo("Order not found: 999999999");
+ }
+
+ @Test
+ void _10_ShouldReturn400NamingTheParameter_WhenPageOrSizeIsOutOfBounds() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+
+ JsonNode page = assertError(support.get(jwt, URL + "?page=-1"), 400);
+ JsonNode zero = assertError(support.get(jwt, URL + "?size=0"), 400);
+ JsonNode big = assertError(support.get(jwt, URL + "?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, URL + "?size=1"), 200);
+ assertSuccess(support.get(jwt, URL + "?size=100"), 200);
+ }
+
+ @Test
+ void _11_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long customerId = customer(jwt);
+ order(jwt, customerId, product(jwt, category(jwt), "1.00"), 1);
+
+ JsonNode data = assertSuccess(support.get(jwt, URL + "?customerId=" + customerId + "&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()).isEqualTo(1L);
+ }
+
+ @Test
+ void _12_ShouldReturn401_WhenNoTokenIsSent() throws Exception {
+ assertError(support.get(null, URL), 401);
+ assertError(support.get(null, URL + "/1"), 401);
+ assertError(support.post(null, URL, orderBody(1, 1, 1)), 401);
+ }
+
+ @Test
+ void _13_ShouldReturn403AndStoreNothing_WhenUserLacksTheWritePermission() throws Exception {
+ TestUser simple = support.createSimpleUser();
+ TestUser readOnly = support.createUser(true, false, support.createRole("ORDER:READ"));
+ String admin = support.createAdmin().getJwt();
+ long customerId = customer(admin);
+ long productId = product(admin, category(admin), "5.00");
+ long id = order(admin, customerId, productId, 1);
+
+ for (TestUser user : List.of(simple, readOnly)) {
+ assertError(support.post(user.getJwt(), URL, orderBody(customerId, productId, 1)), 403);
+ }
+
+ assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ?", customerId)).isEqualTo(1L);
+ // The simple user has no read permission either, the read-only user does
+ assertError(support.get(simple.getJwt(), URL), 403);
+ assertError(support.get(simple.getJwt(), URL + "/" + id), 403);
+ assertSuccess(support.get(readOnly.getJwt(), URL + "/" + id), 200);
+ assertSuccess(support.get(readOnly.getJwt(), URL + "?customerId=" + customerId), 200);
+ }
+
+ @Test
+ void _14_ShouldCreateButNotRead_WhenUserHoldsOnlyOrderWrite() throws Exception {
+ String admin = support.createAdmin().getJwt();
+ long customerId = customer(admin);
+ long productId = product(admin, category(admin), "5.00");
+ TestUser writer = support.createUser(true, false, support.createRole("ORDER:WRITE"));
+
+ long id = order(writer.getJwt(), customerId, productId, 2);
+
+ assertError(support.get(writer.getJwt(), URL + "/" + id), 403);
+ assertError(support.get(writer.getJwt(), URL), 403);
+ }
+
+ @Test
+ void _15_ShouldReturn405_WhenAnOrderIsUpdatedOrDeleted() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long customerId = customer(jwt);
+ long id = order(jwt, customerId, product(jwt, category(jwt), "5.00"), 1);
+
+ assertError(support.put(jwt, URL + "/" + id, orderBody(customerId, 1, 1)), 405);
+ assertError(support.delete(jwt, URL + "/" + id), 405);
+ assertSuccess(support.get(jwt, URL + "/" + id), 200);
+ }
+
+ // Non-regression: Jackson used to truncate a decimal quantity such as 1.5 to 1 and create the order
+ @Test
+ void _16_ShouldReturn400AndStoreNothing_WhenQuantityIsADecimal() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long customerId = customer(jwt);
+ long productId = product(jwt, category(jwt), "5.00");
+
+ assertError(support.post(jwt, URL, orderBody(customerId, productId, "1.5")), 400);
+
+ assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ?", customerId)).isZero();
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractOrderMvcTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractOrderMvcTest.java
new file mode 100644
index 0000000..92b7396
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractOrderMvcTest.java
@@ -0,0 +1,92 @@
+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.order.service.OrderService;
+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 order controller tests: builds MockMvc on the shared OrderMvcTestConfig 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 (same as
+// AbstractCatalogMvcTest): the JWT filter and @PreAuthorize are exercised, only the services are mocked.
+@SpringJUnitWebConfig(OrderMvcTestConfig.class)
+abstract class AbstractOrderMvcTest {
+
+ @Autowired
+ private WebApplicationContext context;
+ @Autowired
+ protected OrderService orderService;
+ @Autowired
+ protected AuthService authService;
+
+ protected MockMvc mockMvc;
+
+ @BeforeEach
+ void setUpMockMvc() {
+ reset(orderService, 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/OrderControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderControllerTest.java
new file mode 100644
index 0000000..61030c9
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderControllerTest.java
@@ -0,0 +1,279 @@
+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.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+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.edgareldy.springjdbctutorial.core.order.dto.OrderDto;
+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 OrderController with OrderService mocked but the real security and validation: statuses, ApiResponse shape, filters, page/size bounds, per-field body validation, the 404/422 refusals and the missing PUT/DELETE.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+class OrderControllerTest extends AbstractOrderMvcTest {
+
+ private static final String READ = "ORDER:READ";
+ private static final String WRITE = "ORDER:WRITE";
+ private static final String URL = "/api/v1/orders";
+ private static final OrderDto ORDER = new OrderDto(1L, 2L, 3L, 3, new BigDecimal("59.97"));
+
+ private static String body(String customerId, String productId, String quantity) {
+ return "{\"customerId\":" + customerId + ",\"productId\":" + productId + ",\"quantity\":" + quantity + "}";
+ }
+
+ // ---------------------------------------------------------------- GET /orders
+
+ @Test
+ void _01_ShouldReturn200WithPageAndDefaults_WhenTokenHoldsOrderRead() throws Exception {
+ when(orderService.list(0, 20, null, null)).thenReturn(new PageDto<>(List.of(ORDER), 0, 20, 1));
+
+ JsonNode data = assertSuccess(call(getWith(URL, token(READ))), 200).get("data");
+
+ JsonNode first = data.get("content").get(0);
+ assertThat(first.get("id").asLong()).isEqualTo(1L);
+ assertThat(first.get("customerId").asLong()).isEqualTo(2L);
+ assertThat(first.get("productId").asLong()).isEqualTo(3L);
+ assertThat(first.get("quantity").asInt()).isEqualTo(3);
+ assertThat(first.get("total").decimalValue()).isEqualByComparingTo("59.97");
+ 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_ShouldPassPageSizeAndBothFiltersToTheService_WhenTheyAreGiven() throws Exception {
+ when(orderService.list(2, 5, 7L, 8L)).thenReturn(new PageDto<>(List.of(), 2, 5, 11));
+
+ JsonNode data = assertSuccess(call(getWith(URL + "?page=2&size=5&customerId=7&productId=8", token(READ))), 200)
+ .get("data");
+
+ assertThat(data.get("page").asInt()).isEqualTo(2);
+ verify(orderService).list(2, 5, 7L, 8L);
+ }
+
+ @Test
+ void _03_ShouldPassOnlyTheCustomerFilter_WhenProductIdIsAbsent() throws Exception {
+ when(orderService.list(0, 20, 7L, null)).thenReturn(new PageDto<>(List.of(ORDER), 0, 20, 1));
+
+ assertSuccess(call(getWith(URL + "?customerId=7", token(READ))), 200);
+
+ verify(orderService).list(0, 20, 7L, null);
+ }
+
+ @Test
+ void _04_ShouldPassOnlyTheProductFilter_WhenCustomerIdIsAbsent() throws Exception {
+ when(orderService.list(0, 20, null, 8L)).thenReturn(new PageDto<>(List.of(ORDER), 0, 20, 1));
+
+ assertSuccess(call(getWith(URL + "?productId=8", token(READ))), 200);
+
+ verify(orderService).list(0, 20, null, 8L);
+ }
+
+ @Test
+ void _05_ShouldReturn400_WhenAFilterIsNotANumber() throws Exception {
+ assertError(call(getWith(URL + "?customerId=abc", token(READ))), 400);
+ assertError(call(getWith(URL + "?productId=abc", token(READ))), 400);
+ verifyNoInteractions(orderService);
+ }
+
+ @Test
+ void _06_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception {
+ when(orderService.list(99, 10, null, null)).thenReturn(new PageDto<>(List.of(), 99, 10, 3));
+
+ JsonNode data = assertSuccess(call(getWith(URL + "?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 _07_ShouldReturn400NamingPage_WhenPageIsNegative() throws Exception {
+ JsonNode json = assertError(call(getWith(URL + "?page=-1", token(READ))), 400);
+
+ assertThat(json.get("message").asText()).contains("page: ").doesNotContain("size: ");
+ verifyNoInteractions(orderService);
+ }
+
+ @Test
+ void _08_ShouldReturn400NamingSize_WhenSizeIsZeroOrAbove100() throws Exception {
+ JsonNode zero = assertError(call(getWith(URL + "?size=0", token(READ))), 400);
+ JsonNode big = assertError(call(getWith(URL + "?size=101", token(READ))), 400);
+
+ assertThat(zero.get("message").asText()).contains("size: ").doesNotContain("page: ");
+ assertThat(big.get("message").asText()).contains("size: ");
+ verifyNoInteractions(orderService);
+ }
+
+ @Test
+ void _09_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() throws Exception {
+ when(orderService.list(0, 1, null, null)).thenReturn(new PageDto<>(List.of(), 0, 1, 0));
+ when(orderService.list(0, 100, null, null)).thenReturn(new PageDto<>(List.of(), 0, 100, 0));
+
+ assertSuccess(call(getWith(URL + "?size=1", token(READ))), 200);
+ assertSuccess(call(getWith(URL + "?size=100", token(READ))), 200);
+ }
+
+ // ---------------------------------------------------------------- GET /orders/{id}
+
+ @Test
+ void _10_ShouldReturn200WithTheOrder_WhenOrderExists() throws Exception {
+ when(orderService.get(1L)).thenReturn(ORDER);
+
+ JsonNode json = assertSuccess(call(getWith(URL + "/1", token(READ))), 200);
+
+ assertThat(json.get("data").get("id").asLong()).isEqualTo(1L);
+ assertThat(json.get("data").get("total").decimalValue()).isEqualByComparingTo("59.97");
+ assertThat(json.get("message").asText()).isEqualTo("Order");
+ }
+
+ @Test
+ void _11_ShouldReturn404ApiResponse_WhenOrderDoesNotExist() throws Exception {
+ when(orderService.get(99L)).thenThrow(new ResourceNotFoundException("Order not found: 99"));
+
+ JsonNode json = assertError(call(getWith(URL + "/99", token(READ))), 404);
+
+ assertThat(json.get("message").asText()).isEqualTo("Order not found: 99");
+ }
+
+ @Test
+ void _12_ShouldReturn400_WhenOrderIdIsNotANumber() throws Exception {
+ assertError(call(getWith(URL + "/abc", token(READ))), 400);
+ verifyNoInteractions(orderService);
+ }
+
+ // ---------------------------------------------------------------- POST /orders
+
+ @Test
+ void _13_ShouldReturn201WithCreatedOrder_WhenBodyIsValid() throws Exception {
+ when(orderService.create(any(OrderDto.class))).thenReturn(ORDER);
+
+ JsonNode json = assertSuccess(call(postJson(URL, token(WRITE), body("2", "3", "3"))), 201);
+
+ assertThat(json.get("data").get("id").asLong()).isEqualTo(1L);
+ assertThat(json.get("data").get("total").decimalValue()).isEqualByComparingTo("59.97");
+ assertThat(json.get("message").asText()).isEqualTo("Order created");
+ ArgumentCaptor sent = ArgumentCaptor.forClass(OrderDto.class);
+ verify(orderService).create(sent.capture());
+ assertThat(sent.getValue()).isEqualTo(new OrderDto(null, 2L, 3L, 3, null));
+ }
+
+ @Test
+ void _14_ShouldIgnoreAClientSuppliedTotal_WhenBodyCarriesOne() throws Exception {
+ when(orderService.create(any(OrderDto.class))).thenReturn(ORDER);
+
+ assertSuccess(call(postJson(URL, token(WRITE),
+ "{\"customerId\":2,\"productId\":3,\"quantity\":3,\"total\":0.01,\"id\":77}")), 201);
+
+ ArgumentCaptor sent = ArgumentCaptor.forClass(OrderDto.class);
+ verify(orderService).create(sent.capture());
+ assertThat(sent.getValue().getTotal()).isNull();
+ assertThat(sent.getValue().getId()).isNull();
+ }
+
+ @Test
+ void _15_ShouldReturn400NamingTheField_WhenCustomerIdOrProductIdIsNull() throws Exception {
+ JsonNode customer = assertError(call(postJson(URL, token(WRITE), body("null", "3", "1"))), 400);
+ JsonNode product = assertError(call(postJson(URL, token(WRITE), body("2", "null", "1"))), 400);
+ JsonNode missing = assertError(call(postJson(URL, token(WRITE), "{\"quantity\":1}")), 400);
+
+ assertThat(customer.get("message").asText()).contains("customerId: ").doesNotContain("productId: ");
+ assertThat(product.get("message").asText()).contains("productId: ").doesNotContain("customerId: ");
+ assertThat(missing.get("message").asText()).contains("customerId: ").contains("productId: ");
+ verifyNoInteractions(orderService);
+ }
+
+ @Test
+ void _16_ShouldReturn400NamingQuantity_WhenQuantityIsMissingZeroOrTooLarge() throws Exception {
+ JsonNode missing = assertError(call(postJson(URL, token(WRITE), "{\"customerId\":2,\"productId\":3}")), 400);
+ JsonNode zero = assertError(call(postJson(URL, token(WRITE), body("2", "3", "0"))), 400);
+ JsonNode negative = assertError(call(postJson(URL, token(WRITE), body("2", "3", "-1"))), 400);
+ JsonNode big = assertError(call(postJson(URL, token(WRITE), body("2", "3", "1000001"))), 400);
+
+ for (JsonNode json : List.of(missing, zero, negative, big)) {
+ assertThat(json.get("message").asText()).contains("quantity: ");
+ }
+ verifyNoInteractions(orderService);
+ }
+
+ @Test
+ void _17_ShouldReturn201_WhenQuantityIsOnItsLimits() throws Exception {
+ when(orderService.create(any(OrderDto.class))).thenReturn(ORDER);
+
+ assertSuccess(call(postJson(URL, token(WRITE), body("2", "3", "1"))), 201);
+ assertSuccess(call(postJson(URL, token(WRITE), body("2", "3", "1000000"))), 201);
+ }
+
+ @Test
+ void _18_ShouldReturn400_WhenBodyIsMalformedJsonOrHasTheWrongTypes() throws Exception {
+ assertError(call(postJson(URL, token(WRITE), "{\"customerId\":")), 400);
+ assertError(call(postJson(URL, token(WRITE), body("\"abc\"", "3", "1"))), 400);
+ verifyNoInteractions(orderService);
+ }
+
+ @Test
+ void _19_ShouldReturn404_WhenTheServiceReportsAnUnknownCustomer() throws Exception {
+ when(orderService.create(any(OrderDto.class))).thenThrow(new ResourceNotFoundException("Customer not found: 2"));
+
+ JsonNode json = assertError(call(postJson(URL, token(WRITE), body("2", "3", "1"))), 404);
+
+ assertThat(json.get("message").asText()).isEqualTo("Customer not found: 2");
+ }
+
+ @Test
+ void _20_ShouldReturn404_WhenTheServiceReportsAnUnknownProduct() throws Exception {
+ when(orderService.create(any(OrderDto.class))).thenThrow(new ResourceNotFoundException("Product not found: 3"));
+
+ JsonNode json = assertError(call(postJson(URL, token(WRITE), body("2", "3", "1"))), 404);
+
+ assertThat(json.get("message").asText()).isEqualTo("Product not found: 3");
+ }
+
+ @Test
+ void _21_ShouldReturn422_WhenTheServiceRefusesTheTotal() throws Exception {
+ when(orderService.create(any(OrderDto.class)))
+ .thenThrow(new BusinessRuleException("Order total must not exceed 999999999999.99"));
+
+ JsonNode json = assertError(call(postJson(URL, token(WRITE), body("2", "3", "1000000"))), 422);
+
+ assertThat(json.get("message").asText()).isEqualTo("Order total must not exceed 999999999999.99");
+ }
+
+ @Test
+ void _22_ShouldReturn415_WhenContentTypeIsNotJson() throws Exception {
+ assertError(call(MockMvcRequestBuilders.post(URL)
+ .header("Authorization", bearer(token(WRITE))).contentType(MediaType.TEXT_PLAIN).content("x")), 415);
+ verifyNoInteractions(orderService);
+ }
+
+ // ---------------------------------------------------------------- orders are immutable
+
+ @Test
+ void _23_ShouldReturn405ApiResponse_WhenPutDeleteOrPatchIsUsed() throws Exception {
+ assertError(call(putJson(URL + "/1", token(WRITE), body("2", "3", "1"))), 405);
+ assertError(call(deleteWith(URL + "/1", token(WRITE))), 405);
+ assertError(call(patchWith(URL + "/1", token(WRITE))), 405);
+ assertError(call(deleteWith(URL, token(WRITE))), 405);
+ verifyNoInteractions(orderService);
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderEndpointSecurityTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderEndpointSecurityTest.java
new file mode 100644
index 0000000..5a28639
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderEndpointSecurityTest.java
@@ -0,0 +1,111 @@
+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.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto;
+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 3 order endpoints of the README table, checked through the real filter chain and method security: no token or 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 reaching the (mocked) service; 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 OrderEndpointSecurityTest extends AbstractOrderMvcTest {
+
+ private static final String BODY = "{\"customerId\":2,\"productId\":3,\"quantity\":1}";
+
+ // 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/orders", null, "ORDER:READ"),
+ Arguments.of(HttpMethod.GET, "/api/v1/orders/1", null, "ORDER:READ"),
+ Arguments.of(HttpMethod.POST, "/api/v1/orders", BODY, "ORDER: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 {
+ assertError(call(request(method, url, body, null)), 401);
+ verifyNoInteractions(orderService);
+ }
+
+ @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(orderService);
+ }
+
+ @ParameterizedTest(name = "{0} {1} with an empty token")
+ @MethodSource("endpoints")
+ void _03_ShouldReturn403ApiResponse_WhenTokenHoldsNoPermissionAtAll(HttpMethod method, String url, String body,
+ String required) throws Exception {
+ assertError(call(request(method, url, body, token())), 403);
+ verifyNoInteractions(orderService);
+ }
+
+ @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(orderService);
+ }
+
+ @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, which would make the controller fail on the converter, so give the paths a real
+ // answer; the point of this test is only that the permission opens the door.
+ OrderDto order = new OrderDto(1L, 2L, 3L, 1, new BigDecimal("10.00"));
+ when(orderService.list(0, 20, null, null)).thenReturn(new PageDto(List.of(), 0, 20, 0));
+ when(orderService.get(1L)).thenReturn(order);
+ when(orderService.create(any())).thenReturn(order);
+
+ 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/OrderMvcTestConfig.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderMvcTestConfig.java
new file mode 100644
index 0000000..db175c3
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderMvcTestConfig.java
@@ -0,0 +1,80 @@
+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.order.service.OrderService;
+import com.edgareldy.springjdbctutorial.ws.config.SecurityConfig;
+import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig;
+import com.edgareldy.springjdbctutorial.ws.controller.OrderController;
+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 order controller tests: MVC, the REAL SecurityConfig (JWT filter, method security
+ * with CustomPermissionEvaluator, 401/403 handlers), the real controller and advice, and a Mockito mock in place
+ * of OrderService (no database).
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+// Same recipe as CatalogMvcTestConfig, kept in ws.mvc and never in ws.controller so WebMvcConfig's component scan
+// cannot pick it up.
+@Configuration
+@EnableWebMvc
+@Import(SecurityConfig.class)
+public class OrderMvcTestConfig implements WebMvcConfigurer {
+
+ private final WebMvcConfig real = new WebMvcConfig();
+
+ @Bean
+ Clock clock() {
+ return Clock.systemUTC();
+ }
+
+ @Bean
+ ObjectMapper objectMapper() {
+ return real.objectMapper();
+ }
+
+ @Bean
+ AuthService authService() {
+ return mock(AuthService.class);
+ }
+
+ @Bean
+ OrderService orderService() {
+ return mock(OrderService.class);
+ }
+
+ @Bean
+ OrderController orderController(OrderService orderService) {
+ return new OrderController(orderService);
+ }
+
+ @Bean
+ GlobalExceptionHandler globalExceptionHandler() {
+ return new GlobalExceptionHandler();
+ }
+
+ @Override
+ public void extendMessageConverters(List> converters) {
+ real.extendMessageConverters(converters);
+ }
+
+ @Override
+ public Validator getValidator() {
+ return real.getValidator();
+ }
+}