From fc9a2362f0b61a18c6bb7d612dd87c708f52b436 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 01/18] feat(order): add the Order entity and OrderDto Order is the table-shaped class for the orders table (the class name is Order, the table is orders because ORDER is an SQL keyword) and OrderDto is the business shape the service exposes. The quantity is an int and the total a BigDecimal, never a double, because it is money. The two shapes stay separate classes. --- .../core/order/dto/OrderDto.java | 105 ++++++++++++++++ .../core/order/entity/Order.java | 116 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dto/OrderDto.java create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/entity/Order.java 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 + + "}"; + } +} From 1b265455cd6dcc0419cc12f719ecad7a25bd6756 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 02/18] feat(order): add the order RowMapper and entity mapper The RowMapper reads one row into an Order with getInt for the quantity and getBigDecimal for the total, so the exact decimal survives the round trip. The entity mapper converts between Order and OrderDto. Neither queries the database or contains a business rule. --- .../core/order/mapper/OrderMapper.java | 31 +++++++++++++++++++ .../core/order/mapper/OrderRowMapper.java | 28 +++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapper.java create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapper.java 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")); + } +} From 8a626fc282ccf5b467113c47c9474690b6bd29a9 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 03/18] feat(order): add the order DAO with optional filters OrderDaoImpl reads a page filtered by an optional customer and an optional product, counts with the same filters, finds by id and inserts with INSERT ... RETURNING id. The WHERE clause is built from the filters that are present: only fixed column names are concatenated and every value is a bound parameter, added in placeholder order. There is no update or delete, because orders are immutable in this API. --- .../core/order/dao/OrderDao.java | 28 +++++++ .../core/order/dao/impl/OrderDaoImpl.java | 78 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/OrderDao.java create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImpl.java 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(); + } +} From c1e3e2a3ad11f1c72e990ae3af4125ed2440fccc Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 04/18] feat(order): implement the order service on top of the catalog and customer services OrderServiceImpl reaches customers and products only through their service interfaces, never their DAOs. The injected beans are transactional proxies, so their calls join the transaction of create, and their ResourceNotFoundException propagates as a 404. The total is the product price times the quantity, computed exactly and compared with the column limit before any scaling. A quantity outside 1 to 1000000 is refused, and a customer or product deleted between the checks and the insert is reported as not found instead of a 500. The service returns dto types and pages through PageDto. --- .../core/order/service/OrderService.java | 24 ++++ .../order/service/impl/OrderServiceImpl.java | 109 ++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/OrderService.java create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java 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..07df08e --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java @@ -0,0 +1,109 @@ +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()); + } + BigDecimal total = exact.setScale(2, RoundingMode.HALF_UP); + if (total.compareTo(MAX_TOTAL) > 0) { + throw new BusinessRuleException("Order total must not exceed " + MAX_TOTAL.toPlainString()); + } + + 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"); + } + } +} From 927bac41f2a0924535234c531309edc440e7ac34 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 05/18] feat(order): wire the order DAO and service explicitly DaoConfig declares the DAO, and ServiceConfig imports it together with the catalog and customer service configurations, by fully qualified name since each module has a class with that simple name. The order module is therefore self-sufficient: the ProductService and CustomerService it injects come from those imported configs. The order service bean is typed by interface so its @Transactional proxy applies. --- .../core/order/config/DaoConfig.java | 24 ++++++++++++++ .../core/order/config/ServiceConfig.java | 31 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/DaoConfig.java create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/ServiceConfig.java 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); + } +} From f7e97bd801e0a82edc139618569d437ecd43d127 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 06/18] feat(ws): add the order payloads and converter OrderRequest requires the customer, the product and a quantity between 1 and 1000000, so a missing or absurd value is a 400 before it reaches the service. OrderResponse exposes the computed total, and OrderConverter maps to and from the core dto and builds the PageResponse. Payload classes stay distinct from core dto classes. --- .../ws/converter/OrderConverter.java | 45 ++++++++ .../ws/payload/order/OrderRequest.java | 83 ++++++++++++++ .../ws/payload/order/OrderResponse.java | 105 ++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/OrderConverter.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderResponse.java 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 + + "}"; + } +} From 9a1a4d85982186409c28368d65b22a53a40558b0 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 07/18] feat(ws): expose the order endpoints OrderController offers the paginated list filterable by customerId and productId, the detail and the creation, guarded by @PreAuthorize("hasPermission('ORDER', 'READ'|'WRITE')"). It only calls OrderService and answers with an ApiResponse, and a creation returns 201. There is no update or delete route, so those methods answer 405. --- .../ws/controller/OrderController.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/OrderController.java 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"); + } +} From bd27d9a84a4425f9be4d9a7e5fcbb760019da16d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 08/18] feat(ws): import the order module into the shared context The order ServiceConfig is added to the WebMvcConfig import list by fully qualified name. It brings the DAO, and through its own imports the catalog and customer services it depends on. With this import every core module is now wired into the single application context. --- .../edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java index 0657ea5..a3e7e60 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 @@ -39,7 +39,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 { From c5fb3cf916206c0f84209a70fb89aceb4a2c26ab Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:09 +0400 Subject: [PATCH 09/18] build(order): add Testcontainers test dependencies to the order module The order DAO tests run against a real PostgreSQL, so the module declares the Testcontainers JUnit and PostgreSQL artifacts and the common test-jar holding the shared singleton container, all with test scope, like the other business modules. --- core/order/pom.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 + From 1b33ba1f08d6b9c8c38566c252169960cb1b896a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:09 +0400 Subject: [PATCH 10/18] test(order): cover the order DAO against PostgreSQL The DAO test loads its own dedicated fixture with customers, products and orders. It checks page ordering, offset and an out-of-range page, each filter alone and both together, counts with the same filters, lookups, the exact NUMERIC total round trip including the column maximum, and the constraints owned by the schema: foreign keys to the customer and the product and the CHECK on the quantity. --- .../core/order/dao/impl/OrderDaoImplTest.java | 148 ++++++++++++++++++ .../src/test/resources/order-dao-dataset.sql | 22 +++ 2 files changed, 170 insertions(+) create mode 100644 core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImplTest.java create mode 100644 core/order/src/test/resources/order-dao-dataset.sql 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/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; From 97ade2067b9df61531c9e26b8c0b8186a2d84fc4 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:09 +0400 Subject: [PATCH 11/18] test(order): test the order RowMapper and entity mapper The RowMapper is fed a mocked ResultSet with an int quantity and a BigDecimal total, and the entity mapper is checked with hand-built objects and no mocks. --- .../core/order/mapper/OrderMapperTest.java | 41 ++++++++++++++ .../core/order/mapper/OrderRowMapperTest.java | 55 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapperTest.java create mode 100644 core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapperTest.java 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); + } +} From fe066f368503d602dbf31fc83bdfac3234d7348a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:10 +0400 Subject: [PATCH 12/18] test(order): unit test the order service with mocked customer and product services The order module only sees service interfaces, so the tests mock CustomerService and ProductService. They cover exact totals (3 x 19.99 = 59.97, a zero price, half-up rounding), a total above the column limit refused before any insert, the quantity boundaries, null ids, and the order of the checks: an unknown customer or product propagates its 404 and nothing is inserted. A foreign key violation at insert becomes a not-found error, and the page bounds and filters are checked too. --- .../service/impl/OrderServiceImplTest.java | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImplTest.java 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"); + } +} From 205e770dd43b7934ca7d62a54e42158626745de7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:10 +0400 Subject: [PATCH 13/18] test(ws): test the order controller with MockMvc The real security configuration and a real signed JWT are used with a mocked service. The tests check status and shape of the three endpoints, validation errors per field, the customer and product filters passed to the service, page and size violations, an out-of-range page keeping content as an empty array, the 404 and 422 mappings, and 405 for PUT, DELETE and PATCH. A client-supplied total or id is shown to be ignored. --- .../ws/mvc/AbstractOrderMvcTest.java | 92 ++++++ .../ws/mvc/OrderControllerTest.java | 279 ++++++++++++++++++ .../ws/mvc/OrderMvcTestConfig.java | 80 +++++ 3 files changed, 451 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractOrderMvcTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderControllerTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderMvcTestConfig.java 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/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(); + } +} From 6df29193344cc8bd0a960995149473ae0979bf05 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:10 +0400 Subject: [PATCH 14/18] test(ws): check the permission matrix of the order endpoints A parameterized test asserts, for each endpoint, that no token or a revoked one gives 401, that a token holding every other permission or none gives 403, and that a token holding only the required ORDER permission succeeds. --- .../ws/mvc/OrderEndpointSecurityTest.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderEndpointSecurityTest.java 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); + } +} From eaf6676cc7ea901e14fe588fbc2d971536360a24 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:10 +0400 Subject: [PATCH 15/18] test(ws): run the order flows on the real stack Customers, categories and products are created through their real endpoints, then the tests prove that the total is computed from the product price at creation and stays after the price changes, the filters and pagination, unknown customers and products giving 404 with nothing stored, and invalid quantities giving 400. Deleting a customer or a product referenced by an order gives 422 with the exact message, then succeeds once the order is removed. A decimal quantity such as 1.5 is refused with 400 and stores nothing, which pins the Jackson fix. --- .../integration/OrderFlowIntegrationTest.java | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/OrderFlowIntegrationTest.java 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(); + } +} From 79f133c21b3aa06c6bc9bbfd19f91e8cff2f65c4 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:10 +0400 Subject: [PATCH 16/18] fix(ws): refuse a decimal where an integer is expected By default Jackson accepts a floating point JSON number for an int field and silently truncates it, so a body with a quantity of 1.5 created an order for one unit instead of being refused. The shared ObjectMapper now disables DeserializationFeature.ACCEPT_FLOAT_AS_INT, so such a body is a malformed request and answers 400 through the existing handler. An integration test on the order endpoint pins the behaviour. --- .../springjdbctutorial/ws/config/WebMvcConfig.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java index a3e7e60..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; @@ -51,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(); } From f8cd73f37e4c67c269be73d0f55a8601b35b2933 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:10 +0400 Subject: [PATCH 17/18] docs(readme): tick the feature/order tasks and correct the order dependencies The three order tasks are checked: the entity, dto, RowMapper, mapper, DAO, service and configuration of core/order, the controller and converter in ws with the WebMvcConfig import, and the tests at all five layers with a dedicated fixture for the DAO tests. The project tree said the order service depends on CategoryService as well, but computing a total and checking the references only needs ProductService and CustomerService, so the README now says so. --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 From 87694e43f32cdb5d800a78ba4ed0a1597ae895b5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:17:23 +0400 Subject: [PATCH 18/18] refactor(order): drop an unreachable second total check The exact product is already compared with the column maximum, which has two decimals, so rounding a value at or below it to two decimals can never exceed it. The second comparison after setScale and its duplicated message could never fire. The rounding itself stays, and the unit tests that pin the totals still pass. --- .../core/order/service/impl/OrderServiceImpl.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index 07df08e..e13eea9 100644 --- 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 @@ -93,10 +93,8 @@ public OrderDto create(OrderDto input) { 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); - if (total.compareTo(MAX_TOTAL) > 0) { - throw new BusinessRuleException("Order total must not exceed " + MAX_TOTAL.toPlainString()); - } Order order = new Order(null, input.getCustomerId(), input.getProductId(), quantity, total); try {