diff --git a/README.md b/README.md index 6cd8409..df312b7 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ spring-jdbc-tutorial/ │ ├── dto/ (OrderDto.java) │ ├── mapper/ (OrderRowMapper.java, OrderMapper.java) │ ├── dao/ (OrderDao.java + impl/) -│ ├── service/ (OrderService.java + impl/, depends on CategoryService/ProductService/CustomerService) +│ ├── service/ (OrderService.java + impl/, depends on ProductService/CustomerService only: the category is never needed) │ └── config/ (DaoConfig.java, ServiceConfig.java) └── ws/ ├── pom.xml (depends on core/auth, core/catalog, core/customer, core/order) @@ -438,11 +438,11 @@ Full CRUD for users, roles, and permissions, still inside `core/auth`. Assignmen ### Tasks -- [ ] `core/order`: `Order` entity (`@Table("orders")`) and `OrderDto`, `OrderRowMapper` (entity) plus `OrderMapper` (entity ↔ dto), `OrderDao` + impl, `OrderService` + impl (depends on `catalog`'s `ProductService` and `customer`'s `CustomerService` - their service interfaces, never their DAOs directly): computes `total = quantity * product.unitPrice`, checks the referenced customer/product exist -- [ ] `core/order`'s `DaoConfig`/`ServiceConfig` (the latter injecting `ProductService`/`CustomerService` beans from the other modules' contexts) -- [ ] `ws`: `OrderController`, `OrderConverter` -- [ ] `WebMvcConfig` updated to import `core/order`'s config -- [ ] Tests at all five layers, `OrderDao` with its own fixture dataset, the total computation, and the not-found cases for a bad `customerId`/`productId` +- [x] `core/order`: `Order` entity (`@Table("orders")`) and `OrderDto`, `OrderRowMapper` (entity) plus `OrderMapper` (entity ↔ dto), `OrderDao` + impl, `OrderService` + impl (depends on `catalog`'s `ProductService` and `customer`'s `CustomerService` - their service interfaces, never their DAOs directly): computes `total = quantity * product.unitPrice`, checks the referenced customer/product exist +- [x] `core/order`'s `DaoConfig`/`ServiceConfig` (the latter injecting `ProductService`/`CustomerService` beans from the other modules' contexts) +- [x] `ws`: `OrderController`, `OrderConverter` +- [x] `WebMvcConfig` updated to import `core/order`'s config +- [x] Tests at all five layers, `OrderDao` with its own fixture dataset, the total computation, and the not-found cases for a bad `customerId`/`productId` ## Order of work diff --git a/core/order/pom.xml b/core/order/pom.xml index a3ac330..35456e2 100644 --- a/core/order/pom.xml +++ b/core/order/pom.xml @@ -23,5 +23,22 @@ com.edgareldy.springjdbctutorial customer + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/DaoConfig.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/DaoConfig.java new file mode 100644 index 0000000..8ec6e9e --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/DaoConfig.java @@ -0,0 +1,24 @@ +package com.edgareldy.springjdbctutorial.core.order.config; + +import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao; +import com.edgareldy.springjdbctutorial.core.order.dao.impl.OrderDaoImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Declares the DAO beans of the order module, typed by interface (no stereotype on the implementations). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +public class DaoConfig { + + @Bean + public OrderDao orderDao(JdbcTemplate jdbcTemplate) { + return new OrderDaoImpl(jdbcTemplate); + } +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/ServiceConfig.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/ServiceConfig.java new file mode 100644 index 0000000..41d2033 --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/ServiceConfig.java @@ -0,0 +1,31 @@ +package com.edgareldy.springjdbctutorial.core.order.config; + +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService; +import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao; +import com.edgareldy.springjdbctutorial.core.order.service.OrderService; +import com.edgareldy.springjdbctutorial.core.order.service.impl.OrderServiceImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * Declares the service beans of the order module. Imports its DaoConfig and the catalog and customer + * ServiceConfig (fully qualified, every module has a class of that name) so importing this class alone is enough. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +@Import({DaoConfig.class, + com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class, + com.edgareldy.springjdbctutorial.core.customer.config.ServiceConfig.class}) +public class ServiceConfig { + + @Bean + public OrderService orderService(OrderDao orderDao, ProductService productService, CustomerService customerService) { + return new OrderServiceImpl(orderDao, productService, customerService); + } +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/OrderDao.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/OrderDao.java new file mode 100644 index 0000000..857b863 --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/OrderDao.java @@ -0,0 +1,28 @@ +package com.edgareldy.springjdbctutorial.core.order.dao; + +import com.edgareldy.springjdbctutorial.core.order.entity.Order; + +import java.util.List; +import java.util.Optional; + +/** + * Data access for orders. Entity types only. Orders are immutable: there is no update and no delete. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface OrderDao { + + /** One page of orders ordered by id, each filter is applied only when not null. */ + List findPage(int page, int size, Long customerId, Long productId); + + /** Number of orders matching the same optional filters. */ + long count(Long customerId, Long productId); + + Optional findById(Long id); + + /** Inserts the order and returns it with its generated id. */ + Order insert(Order order); +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImpl.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImpl.java new file mode 100644 index 0000000..115d176 --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImpl.java @@ -0,0 +1,78 @@ +package com.edgareldy.springjdbctutorial.core.order.dao.impl; + +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao; +import com.edgareldy.springjdbctutorial.core.order.entity.Order; +import com.edgareldy.springjdbctutorial.core.order.mapper.OrderRowMapper; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * JdbcTemplate implementation of OrderDao. Declared by @Bean in DaoConfig, no stereotype. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderDaoImpl extends AbstractDao implements OrderDao { + + private static final String SELECT = "SELECT id, customer_id, product_id, quantity, total FROM orders"; + + private final OrderRowMapper rowMapper = new OrderRowMapper(); + + public OrderDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public List findPage(int page, int size, Long customerId, Long productId) { + List args = new ArrayList<>(); + String where = where(customerId, productId, args); + args.add(size); + args.add((long) page * size); + return getJdbcTemplate().query(SELECT + where + " ORDER BY id LIMIT ? OFFSET ?", rowMapper, args.toArray()); + } + + @Override + public long count(Long customerId, Long productId) { + List args = new ArrayList<>(); + String where = where(customerId, productId, args); + Long total = getJdbcTemplate().queryForObject("SELECT COUNT(*) FROM orders" + where, Long.class, args.toArray()); + return total == null ? 0L : total; + } + + @Override + public Optional findById(Long id) { + return findOne(SELECT + " WHERE id = ?", rowMapper, id); + } + + @Override + public Order insert(Order order) { + long id = insertReturningId( + "INSERT INTO orders (customer_id, product_id, quantity, total) VALUES (?, ?, ?, ?) RETURNING id", + order.getCustomerId(), order.getProductId(), order.getQuantity(), order.getTotal()); + order.setId(id); + return order; + } + + /** + * Builds the WHERE clause from the non-null filters. Only fixed column names are concatenated, + * the values are always bound as parameters and appended to args in placeholder order. + */ + private static String where(Long customerId, Long productId, List args) { + StringBuilder where = new StringBuilder(); + if (customerId != null) { + where.append(" WHERE customer_id = ?"); + args.add(customerId); + } + if (productId != null) { + where.append(customerId == null ? " WHERE" : " AND").append(" product_id = ?"); + args.add(productId); + } + return where.toString(); + } +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dto/OrderDto.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dto/OrderDto.java new file mode 100644 index 0000000..a56053c --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dto/OrderDto.java @@ -0,0 +1,105 @@ +package com.edgareldy.springjdbctutorial.core.order.dto; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Business shape of an order. The total is computed by the service, never taken from the caller. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderDto { + + private Long id; + + private Long customerId; + + private Long productId; + + private int quantity; + + private BigDecimal total; + + public OrderDto() { + } + + public OrderDto(Long id, Long customerId, Long productId, int quantity, BigDecimal total) { + this.id = id; + this.customerId = customerId; + this.productId = productId; + this.quantity = quantity; + this.total = total; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public int getQuantity() { + return quantity; + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } + + public BigDecimal getTotal() { + return total; + } + + public void setTotal(BigDecimal total) { + this.total = total; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + OrderDto that = (OrderDto) o; + return quantity == that.quantity && Objects.equals(id, that.id) && Objects.equals(customerId, that.customerId) + && Objects.equals(productId, that.productId) && Objects.equals(total, that.total); + } + + @Override + public int hashCode() { + return Objects.hash(id, customerId, productId, quantity, total); + } + + @Override + public String toString() { + return "OrderDto{" + + "id=" + id + + ", customerId=" + customerId + + ", productId=" + productId + + ", quantity=" + quantity + + ", total=" + total + + "}"; + } +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/entity/Order.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/entity/Order.java new file mode 100644 index 0000000..e81c9ef --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/entity/Order.java @@ -0,0 +1,116 @@ +package com.edgareldy.springjdbctutorial.core.order.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Row of the orders table. Both references are plain ids: the foreign keys are enforced by the database only. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "orders") +public class Order { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "customer_id") + private Long customerId; + + @Column(name = "product_id") + private Long productId; + + @Column(name = "quantity") + private int quantity; + + @Column(name = "total") + private BigDecimal total; + + public Order() { + } + + public Order(Long id, Long customerId, Long productId, int quantity, BigDecimal total) { + this.id = id; + this.customerId = customerId; + this.productId = productId; + this.quantity = quantity; + this.total = total; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public int getQuantity() { + return quantity; + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } + + public BigDecimal getTotal() { + return total; + } + + public void setTotal(BigDecimal total) { + this.total = total; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Order that = (Order) o; + return quantity == that.quantity && Objects.equals(id, that.id) && Objects.equals(customerId, that.customerId) + && Objects.equals(productId, that.productId) && Objects.equals(total, that.total); + } + + @Override + public int hashCode() { + return Objects.hash(id, customerId, productId, quantity, total); + } + + @Override + public String toString() { + return "Order{" + + "id=" + id + + ", customerId=" + customerId + + ", productId=" + productId + + ", quantity=" + quantity + + ", total=" + total + + "}"; + } +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapper.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapper.java new file mode 100644 index 0000000..899a9bf --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapper.java @@ -0,0 +1,31 @@ +package com.edgareldy.springjdbctutorial.core.order.mapper; + +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; +import com.edgareldy.springjdbctutorial.core.order.entity.Order; + +/** + * Converts between the Order entity and the OrderDto. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderMapper { + + public OrderDto toDto(Order order) { + if (order == null) { + return null; + } + return new OrderDto(order.getId(), order.getCustomerId(), order.getProductId(), + order.getQuantity(), order.getTotal()); + } + + public Order toEntity(OrderDto dto) { + if (dto == null) { + return null; + } + return new Order(dto.getId(), dto.getCustomerId(), dto.getProductId(), + dto.getQuantity(), dto.getTotal()); + } +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapper.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapper.java new file mode 100644 index 0000000..f30cc7d --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapper.java @@ -0,0 +1,28 @@ +package com.edgareldy.springjdbctutorial.core.order.mapper; + +import com.edgareldy.springjdbctutorial.core.order.entity.Order; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the orders table into an Order entity. Every column is NOT NULL. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderRowMapper implements RowMapper { + + @Override + public Order mapRow(ResultSet rs, int rowNum) throws SQLException { + return new Order( + rs.getLong("id"), + rs.getLong("customer_id"), + rs.getLong("product_id"), + rs.getInt("quantity"), + rs.getBigDecimal("total")); + } +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/OrderService.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/OrderService.java new file mode 100644 index 0000000..d940766 --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/OrderService.java @@ -0,0 +1,24 @@ +package com.edgareldy.springjdbctutorial.core.order.service; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; + +/** + * Business operations on orders. Dto types only at this boundary. Orders are immutable. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface OrderService { + + /** One page of orders, optionally restricted to a customer and/or a product (filters may be null). */ + PageDto list(int page, int size, Long customerId, Long productId); + + /** Throws ResourceNotFoundException when the id is unknown. */ + OrderDto get(Long id); + + /** The customer and the product must exist. The total is computed as unit price times quantity. */ + OrderDto create(OrderDto input); +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java new file mode 100644 index 0000000..e13eea9 --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java @@ -0,0 +1,107 @@ +package com.edgareldy.springjdbctutorial.core.order.service.impl; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService; +import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao; +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; +import com.edgareldy.springjdbctutorial.core.order.entity.Order; +import com.edgareldy.springjdbctutorial.core.order.mapper.OrderMapper; +import com.edgareldy.springjdbctutorial.core.order.service.OrderService; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; + +/** + * Implementation of OrderService. Declared by @Bean in ServiceConfig (no stereotype) and returned as the interface so the @Transactional advice applies through the proxy. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderServiceImpl implements OrderService { + + private static final int MAX_PAGE_SIZE = 100; + private static final int MIN_QUANTITY = 1; + private static final int MAX_QUANTITY = 1_000_000; + /** Largest value of the NUMERIC(14,2) total column. */ + private static final BigDecimal MAX_TOTAL = new BigDecimal("999999999999.99"); + + private final OrderDao orderDao; + private final ProductService productService; + private final CustomerService customerService; + private final OrderMapper mapper = new OrderMapper(); + + public OrderServiceImpl(OrderDao orderDao, ProductService productService, CustomerService customerService) { + this.orderDao = orderDao; + this.productService = productService; + this.customerService = customerService; + } + + @Override + @Transactional(readOnly = true) + public PageDto list(int page, int size, Long customerId, Long productId) { + if (page < 0) { + throw new BusinessRuleException("Page must be greater than or equal to 0"); + } + if (size < 1 || size > MAX_PAGE_SIZE) { + throw new BusinessRuleException("Size must be between 1 and " + MAX_PAGE_SIZE); + } + List content = new ArrayList<>(); + for (Order order : orderDao.findPage(page, size, customerId, productId)) { + content.add(mapper.toDto(order)); + } + return new PageDto<>(content, page, size, orderDao.count(customerId, productId)); + } + + @Override + @Transactional(readOnly = true) + public OrderDto get(Long id) { + return mapper.toDto(orderDao.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Order not found: " + id))); + } + + @Override + @Transactional + public OrderDto create(OrderDto input) { + if (input.getCustomerId() == null) { + throw new BusinessRuleException("Customer id must not be null"); + } + if (input.getProductId() == null) { + throw new BusinessRuleException("Product id must not be null"); + } + int quantity = input.getQuantity(); + if (quantity < MIN_QUANTITY || quantity > MAX_QUANTITY) { + throw new BusinessRuleException("Quantity must be between " + MIN_QUANTITY + " and " + MAX_QUANTITY); + } + // Cross-module dependency: the order module reaches customers and products only through their + // SERVICE interfaces (never their DAOs). The beans are the transactional proxies, so these calls + // join this transaction, and their ResourceNotFoundException propagates as a 404. + customerService.get(input.getCustomerId()); + ProductDto product = productService.get(input.getProductId()); + + // The exact product is compared with the column limit before any scaling + BigDecimal exact = product.getUnitPrice().multiply(BigDecimal.valueOf(quantity)); + if (exact.compareTo(MAX_TOTAL) > 0) { + throw new BusinessRuleException("Order total must not exceed " + MAX_TOTAL.toPlainString()); + } + // MAX_TOTAL has two decimals, so rounding a value at or below it to two decimals cannot exceed it + BigDecimal total = exact.setScale(2, RoundingMode.HALF_UP); + + Order order = new Order(null, input.getCustomerId(), input.getProductId(), quantity, total); + try { + return mapper.toDto(orderDao.insert(order)); + } catch (DataIntegrityViolationException e) { + // The customer or the product was deleted between the checks and the insert (foreign key) + throw new ResourceNotFoundException("Customer or product no longer exists"); + } + } +} diff --git a/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImplTest.java b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImplTest.java new file mode 100644 index 0000000..74737d3 --- /dev/null +++ b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImplTest.java @@ -0,0 +1,148 @@ +package com.edgareldy.springjdbctutorial.core.order.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.core.order.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao; +import com.edgareldy.springjdbctutorial.core.order.entity.Order; +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests OrderDaoImpl against the real PostgreSQL, with its own fixture file order-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Testcontainers starts a throw-away PostgreSQL in Docker (shared by every test class of the JVM through +// PostgresTestContainer); @DynamicPropertySource injects its url into the Spring Environment before the context +// is built, so DataSourceConfig connects to it and Flyway migrates the REAL schema. @Sql runs this class's own +// fixture file before EACH test so every test starts from the same rows. +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/order-dao-dataset.sql") +class OrderDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private OrderDao orderDao; + + @Test + void _01_ShouldReturnFirstPageOrderedById_WhenNoFilterIsGiven() { + assertThat(orderDao.findPage(0, 3, null, null)).extracting(Order::getId).containsExactly(1L, 2L, 3L); + } + + @Test + void _02_ShouldApplyOffsetOfPageTimesSize_WhenPageIsOne() { + assertThat(orderDao.findPage(1, 3, null, null)).extracting(Order::getId).containsExactly(4L, 5L); + } + + @Test + void _03_ShouldReturnEmptyList_WhenPageIsBeyondTheLastOne() { + assertThat(orderDao.findPage(50, 10, null, null)).isEmpty(); + } + + @Test + void _04_ShouldReturnOnlyThatCustomersOrders_WhenFilteringByCustomerId() { + assertThat(orderDao.findPage(0, 10, 2L, null)).extracting(Order::getId).containsExactly(3L, 4L); + } + + @Test + void _05_ShouldReturnOnlyThatProductsOrders_WhenFilteringByProductId() { + assertThat(orderDao.findPage(0, 10, null, 2L)).extracting(Order::getId).containsExactly(2L, 4L); + } + + @Test + void _06_ShouldCombineBothFilters_WhenCustomerIdAndProductIdAreGiven() { + assertThat(orderDao.findPage(0, 10, 1L, 1L)).extracting(Order::getId).containsExactly(1L, 5L); + } + + @Test + void _07_ShouldPaginateInsideTheFilter_WhenFilterAndPageAreCombined() { + assertThat(orderDao.findPage(1, 2, 1L, null)).extracting(Order::getId).containsExactly(5L); + } + + @Test + void _08_ShouldReturnEmptyList_WhenNoOrderMatchesTheFilters() { + assertThat(orderDao.findPage(0, 10, 999L, null)).isEmpty(); + assertThat(orderDao.findPage(0, 10, null, 999L)).isEmpty(); + assertThat(orderDao.findPage(0, 10, 2L, 999L)).isEmpty(); + } + + @Test + void _09_ShouldCountWithTheSameFilters_WhenCounting() { + assertThat(orderDao.count(null, null)).isEqualTo(5L); + assertThat(orderDao.count(1L, null)).isEqualTo(3L); + assertThat(orderDao.count(null, 2L)).isEqualTo(2L); + assertThat(orderDao.count(1L, 1L)).isEqualTo(2L); + assertThat(orderDao.count(2L, 999L)).isZero(); + } + + @Test + void _10_ShouldReturnEveryColumn_WhenIdExists() { + Order order = orderDao.findById(2L).orElseThrow(); + + assertThat(order).isEqualTo(new Order(2L, 1L, 2L, 3, new BigDecimal("59.97"))); + } + + @Test + void _11_ShouldReturnEmpty_WhenIdDoesNotExist() { + assertThat(orderDao.findById(999L)).isEmpty(); + } + + @Test + void _12_ShouldGenerateIdAndKeepTheExactTotal_WhenOrderIsInserted() { + Order created = orderDao.insert(new Order(null, 2L, 2L, 7, new BigDecimal("139.93"))); + + assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + Order read = orderDao.findById(created.getId()).orElseThrow(); + assertThat(read).isEqualTo(created); + assertThat(read.getTotal()).isEqualByComparingTo("139.93"); + assertThat(orderDao.count(null, null)).isEqualTo(6L); + } + + @Test + void _13_ShouldRoundTripTheLargestTotal_WhenTotalIsTheColumnMaximum() { + Order created = orderDao.insert(new Order(null, 1L, 1L, 1, new BigDecimal("999999999999.99"))); + + assertThat(orderDao.findById(created.getId()).orElseThrow().getTotal()).isEqualByComparingTo("999999999999.99"); + } + + @Test + void _14_ShouldThrowDataIntegrityViolationException_WhenCustomerDoesNotExist() { + assertThatThrownBy(() -> orderDao.insert(new Order(null, 999L, 1L, 1, new BigDecimal("10.00")))) + .isInstanceOf(DataIntegrityViolationException.class); + + assertThat(orderDao.count(null, null)).isEqualTo(5L); + } + + @Test + void _15_ShouldThrowDataIntegrityViolationException_WhenProductDoesNotExist() { + assertThatThrownBy(() -> orderDao.insert(new Order(null, 1L, 999L, 1, new BigDecimal("10.00")))) + .isInstanceOf(DataIntegrityViolationException.class); + + assertThat(orderDao.count(null, null)).isEqualTo(5L); + } + + @Test + void _16_ShouldThrowDataIntegrityViolationException_WhenQuantityIsNotPositive() { + assertThatThrownBy(() -> orderDao.insert(new Order(null, 1L, 1L, 0, new BigDecimal("0.00")))) + .isInstanceOf(DataIntegrityViolationException.class); + + assertThat(orderDao.count(null, null)).isEqualTo(5L); + } +} diff --git a/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapperTest.java b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapperTest.java new file mode 100644 index 0000000..43e6744 --- /dev/null +++ b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapperTest.java @@ -0,0 +1,41 @@ +package com.edgareldy.springjdbctutorial.core.order.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; +import com.edgareldy.springjdbctutorial.core.order.entity.Order; +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +/** + * Tests OrderMapper: entity to dto and dto to entity, with hand-built objects and no mock. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class OrderMapperTest { + + private final OrderMapper mapper = new OrderMapper(); + + @Test + void _01_ShouldCopyEveryField_WhenEntityBecomesDto() { + OrderDto dto = mapper.toDto(new Order(1L, 2L, 3L, 4, new BigDecimal("79.96"))); + + assertThat(dto).isEqualTo(new OrderDto(1L, 2L, 3L, 4, new BigDecimal("79.96"))); + } + + @Test + void _02_ShouldCopyEveryField_WhenDtoBecomesEntity() { + Order order = mapper.toEntity(new OrderDto(null, 2L, 3L, 4, new BigDecimal("79.96"))); + + assertThat(order).isEqualTo(new Order(null, 2L, 3L, 4, new BigDecimal("79.96"))); + } + + @Test + void _03_ShouldReturnNull_WhenTheInputIsNull() { + assertThat(mapper.toDto(null)).isNull(); + assertThat(mapper.toEntity(null)).isNull(); + } +} diff --git a/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapperTest.java b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapperTest.java new file mode 100644 index 0000000..ba66040 --- /dev/null +++ b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapperTest.java @@ -0,0 +1,55 @@ +package com.edgareldy.springjdbctutorial.core.order.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.order.entity.Order; +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +/** + * Tests OrderRowMapper against a mocked ResultSet, without any database. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class OrderRowMapperTest { + + private final OrderRowMapper mapper = new OrderRowMapper(); + + // Mockito.mock(ResultSet.class) is a fake row whose getters return what the test stubs, column by + // column: the test only checks the column-to-field mapping, with no database and no driver. + @Test + void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(7L); + when(rs.getLong("customer_id")).thenReturn(3L); + when(rs.getLong("product_id")).thenReturn(4L); + when(rs.getInt("quantity")).thenReturn(3); + when(rs.getBigDecimal("total")).thenReturn(new BigDecimal("59.97")); + + Order order = mapper.mapRow(rs, 0); + + assertThat(order).isEqualTo(new Order(7L, 3L, 4L, 3, new BigDecimal("59.97"))); + } + + @Test + void _02_ShouldKeepTheExactDecimalScale_WhenTotalIsZero() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(8L); + when(rs.getLong("customer_id")).thenReturn(1L); + when(rs.getLong("product_id")).thenReturn(1L); + when(rs.getInt("quantity")).thenReturn(1); + when(rs.getBigDecimal("total")).thenReturn(new BigDecimal("0.00")); + + Order order = mapper.mapRow(rs, 0); + + assertThat(order.getTotal()).isEqualTo(new BigDecimal("0.00")); + assertThat(order.getQuantity()).isEqualTo(1); + } +} diff --git a/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImplTest.java b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImplTest.java new file mode 100644 index 0000000..162b8a9 --- /dev/null +++ b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImplTest.java @@ -0,0 +1,287 @@ +package com.edgareldy.springjdbctutorial.core.order.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService; +import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao; +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; +import com.edgareldy.springjdbctutorial.core.order.entity.Order; +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +/** + * Unit tests of OrderServiceImpl: the DAO and the customer and product SERVICES are Mockito mocks (the order module only sees service interfaces), assertions are made on the dto objects. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// MockitoExtension builds the @Mock fields before each test and fails a test whose stubbing is never used +// (strict stubs), so no test keeps a stub it does not need. No Spring context and no database here. +@ExtendWith(MockitoExtension.class) +class OrderServiceImplTest { + + @Mock + private OrderDao orderDao; + @Mock + private ProductService productService; + @Mock + private CustomerService customerService; + + private OrderServiceImpl service() { + return new OrderServiceImpl(orderDao, productService, customerService); + } + + private static OrderDto input(Long customerId, Long productId, int quantity) { + return new OrderDto(null, customerId, productId, quantity, null); + } + + private void productAt(String unitPrice) { + when(productService.get(2L)).thenReturn(new ProductDto(2L, 1L, "Atlas", new BigDecimal(unitPrice))); + } + + private void insertEchoesWithId() { + when(orderDao.insert(any(Order.class))).thenAnswer(call -> { + Order order = call.getArgument(0); + order.setId(50L); + return order; + }); + } + + private Order inserted() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Order.class); + verify(orderDao).insert(captor.capture()); + return captor.getValue(); + } + + // ---------------------------------------------------------------- create: total + + @Test + void _01_ShouldComputeTheExactTotal_WhenQuantityIsThreeAndPriceIs1999() { + when(customerService.get(1L)).thenReturn(new CustomerDto(1L, "Alice", "Martin", null, null, null)); + productAt("19.99"); + insertEchoesWithId(); + + OrderDto created = service().create(input(1L, 2L, 3)); + + assertThat(created).isEqualTo(new OrderDto(50L, 1L, 2L, 3, new BigDecimal("59.97"))); + assertThat(inserted().getTotal()).isEqualTo(new BigDecimal("59.97")); + } + + @Test + void _02_ShouldStoreTotalZeroWithTwoDecimals_WhenProductIsFree() { + productAt("0.00"); + insertEchoesWithId(); + + service().create(input(1L, 2L, 1)); + + assertThat(inserted().getTotal()).isEqualTo(new BigDecimal("0.00")); + } + + @Test + void _03_ShouldAcceptTheLargestTotal_WhenMillionUnitsAtTheHighestSafePrice() { + productAt("999999.99"); + insertEchoesWithId(); + + service().create(input(1L, 2L, 1_000_000)); + + assertThat(inserted().getTotal()).isEqualTo(new BigDecimal("999999990000.00")); + } + + @Test + void _04_ShouldRefuseAndInsertNothing_WhenTotalExceedsTheColumnLimit() { + productAt("1000000.00"); + + assertThatThrownBy(() -> service().create(input(1L, 2L, 1_000_000))) + .isInstanceOf(BusinessRuleException.class) + .hasMessage("Order total must not exceed 999999999999.99"); + + verify(orderDao, never()).insert(any()); + } + + @Test + void _05_ShouldRefuseAndInsertNothing_WhenTotalIsJustAboveTheLimit() { + productAt("1000000000000.00"); + + assertThatThrownBy(() -> service().create(input(1L, 2L, 1))).isInstanceOf(BusinessRuleException.class); + + verify(orderDao, never()).insert(any()); + } + + @Test + void _06_ShouldRoundHalfUpToTwoDecimals_WhenProductPriceHasMoreDecimals() { + productAt("0.005"); + insertEchoesWithId(); + + service().create(input(1L, 2L, 1)); + + assertThat(inserted().getTotal()).isEqualTo(new BigDecimal("0.01")); + } + + // ---------------------------------------------------------------- create: quantity and ids + + @ParameterizedTest + @ValueSource(ints = {0, -1, 1_000_001}) + void _07_ShouldRefuseAndTouchNothing_WhenQuantityIsOutOfRange(int quantity) { + assertThatThrownBy(() -> service().create(input(1L, 2L, quantity))) + .isInstanceOf(BusinessRuleException.class) + .hasMessage("Quantity must be between 1 and 1000000"); + + verifyNoInteractions(orderDao, productService, customerService); + } + + @ParameterizedTest + @ValueSource(ints = {1, 1_000_000}) + void _08_ShouldAcceptTheBoundary_WhenQuantityIsOneOrAMillion(int quantity) { + productAt("0.50"); + insertEchoesWithId(); + + service().create(input(1L, 2L, quantity)); + + assertThat(inserted().getQuantity()).isEqualTo(quantity); + } + + @Test + void _09_ShouldRefuseAndTouchNothing_WhenCustomerIdIsNull() { + assertThatThrownBy(() -> service().create(input(null, 2L, 1))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Customer id must not be null"); + + verifyNoInteractions(orderDao, productService, customerService); + } + + @Test + void _10_ShouldRefuseAndTouchNothing_WhenProductIdIsNull() { + assertThatThrownBy(() -> service().create(input(1L, null, 1))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Product id must not be null"); + + verifyNoInteractions(orderDao, productService, customerService); + } + + // ---------------------------------------------------------------- create: other modules + + @Test + void _11_ShouldPropagateNotFoundAndNotCallTheProductService_WhenCustomerIsUnknown() { + when(customerService.get(9L)).thenThrow(new ResourceNotFoundException("Customer not found: 9")); + + assertThatThrownBy(() -> service().create(input(9L, 2L, 1))) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Customer not found: 9"); + + verifyNoInteractions(productService, orderDao); + } + + @Test + void _12_ShouldPropagateNotFoundAndInsertNothing_WhenProductIsUnknown() { + when(customerService.get(1L)).thenReturn(new CustomerDto(1L, "Alice", "Martin", null, null, null)); + when(productService.get(9L)).thenThrow(new ResourceNotFoundException("Product not found: 9")); + + assertThatThrownBy(() -> service().create(input(1L, 9L, 1))) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Product not found: 9"); + + verifyNoInteractions(orderDao); + } + + @Test + void _13_ShouldTurnIntoNotFound_WhenTheForeignKeyFailsAtInsert() { + productAt("10.00"); + when(orderDao.insert(any(Order.class))).thenThrow(new DataIntegrityViolationException("fk")); + + assertThatThrownBy(() -> service().create(input(1L, 2L, 1))) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessage("Customer or product no longer exists"); + } + + // ---------------------------------------------------------------- list and get + + @Test + void _14_ShouldReturnDtosAndPassFiltersToTheDao_WhenListingWithFilters() { + when(orderDao.findPage(1, 5, 3L, 4L)).thenReturn(List.of(new Order(9L, 3L, 4L, 2, new BigDecimal("5.00")))); + when(orderDao.count(3L, 4L)).thenReturn(6L); + + PageDto page = service().list(1, 5, 3L, 4L); + + assertThat(page.getContent()).containsExactly(new OrderDto(9L, 3L, 4L, 2, new BigDecimal("5.00"))); + assertThat(page.getPage()).isEqualTo(1); + assertThat(page.getSize()).isEqualTo(5); + assertThat(page.getTotalElements()).isEqualTo(6L); + } + + @Test + void _15_ShouldPassNullFiltersToTheDao_WhenListingWithoutFilters() { + when(orderDao.findPage(0, 20, null, null)).thenReturn(List.of()); + when(orderDao.count(null, null)).thenReturn(0L); + + assertThat(service().list(0, 20, null, null).getContent()).isEmpty(); + } + + @Test + void _16_ShouldKeepEmptyContentAndTotal_WhenPageIsBeyondTheLastOne() { + when(orderDao.findPage(99, 10, null, null)).thenReturn(List.of()); + when(orderDao.count(null, null)).thenReturn(3L); + + PageDto page = service().list(99, 10, null, null); + + assertThat(page.getContent()).isEmpty(); + assertThat(page.getTotalElements()).isEqualTo(3L); + } + + @Test + void _17_ShouldRefuseAndNotQuery_WhenPageIsNegative() { + assertThatThrownBy(() -> service().list(-1, 10, null, null)).isInstanceOf(BusinessRuleException.class); + + verifyNoInteractions(orderDao); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1, 101}) + void _18_ShouldRefuseAndNotQuery_WhenSizeIsOutOfBounds(int size) { + assertThatThrownBy(() -> service().list(0, size, null, null)).isInstanceOf(BusinessRuleException.class); + + verifyNoInteractions(orderDao); + } + + @ParameterizedTest + @ValueSource(ints = {1, 100}) + void _19_ShouldAcceptTheBoundarySizes_WhenSizeIsOneOrOneHundred(int size) { + when(orderDao.findPage(0, size, null, null)).thenReturn(List.of()); + when(orderDao.count(null, null)).thenReturn(0L); + + assertThat(service().list(0, size, null, null).getSize()).isEqualTo(size); + } + + @Test + void _20_ShouldReturnTheDto_WhenOrderExists() { + when(orderDao.findById(9L)).thenReturn(Optional.of(new Order(9L, 3L, 4L, 2, new BigDecimal("5.00")))); + + assertThat(service().get(9L)).isEqualTo(new OrderDto(9L, 3L, 4L, 2, new BigDecimal("5.00"))); + } + + @Test + void _21_ShouldThrowNotFound_WhenOrderDoesNotExist() { + when(orderDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().get(99L)) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Order not found: 99"); + } +} diff --git a/core/order/src/test/resources/order-dao-dataset.sql b/core/order/src/test/resources/order-dao-dataset.sql new file mode 100644 index 0000000..2526ca0 --- /dev/null +++ b/core/order/src/test/resources/order-dao-dataset.sql @@ -0,0 +1,22 @@ +-- Fixture of OrderDaoImplTest only. Wipes the order, catalog and customer tables, then loads two customers (1, 2), +-- one category, two products (1 at 10.00, 2 at 19.99) and five orders: 1 (customer 1, product 1), 2 (customer 1, +-- product 2), 3 (customer 2, product 1), 4 (customer 2, product 2), 5 (customer 1, product 1). +TRUNCATE TABLE orders, products, categories, customers RESTART IDENTITY CASCADE; + +INSERT INTO customers (id, first_name, last_name) VALUES (1, 'Alice', 'Martin'), (2, 'Bob', 'Stone'); + +INSERT INTO categories (id, category_name) VALUES (1, 'Books'); +INSERT INTO products (id, category_id, product_name, unit_price) VALUES (1, 1, 'Novel', 10.00), (2, 1, 'Atlas', 19.99); + +INSERT INTO orders (id, customer_id, product_id, quantity, total) VALUES + (1, 1, 1, 2, 20.00), + (2, 1, 2, 3, 59.97), + (3, 2, 1, 1, 10.00), + (4, 2, 2, 5, 99.95), + (5, 1, 1, 4, 40.00); + +-- Explicit ids do not advance the sequences: move them high so generated ids never collide +ALTER SEQUENCE customers_id_seq RESTART WITH 100; +ALTER SEQUENCE categories_id_seq RESTART WITH 100; +ALTER SEQUENCE products_id_seq RESTART WITH 100; +ALTER SEQUENCE orders_id_seq RESTART WITH 100; diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java index 0657ea5..2f3ecc7 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -1,5 +1,6 @@ package com.edgareldy.springjdbctutorial.ws.config; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.edgareldy.springjdbctutorial.core.common.config.CommonConfig; @@ -39,7 +40,8 @@ @EnableWebMvc @Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class, com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class, - com.edgareldy.springjdbctutorial.core.customer.config.ServiceConfig.class, SecurityConfig.class}) + com.edgareldy.springjdbctutorial.core.customer.config.ServiceConfig.class, + com.edgareldy.springjdbctutorial.core.order.config.ServiceConfig.class, SecurityConfig.class}) @ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"}) public class WebMvcConfig implements WebMvcConfigurer { @@ -50,7 +52,10 @@ public class WebMvcConfig implements WebMvcConfigurer { @Bean public ObjectMapper objectMapper() { return Jackson2ObjectMapperBuilder.json() - .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, + // Jackson would silently truncate 1.5 to 1 for an int field, i.e. order one unit + // instead of refusing the body: a decimal is never a valid integer here + DeserializationFeature.ACCEPT_FLOAT_AS_INT) .build(); } diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/OrderController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/OrderController.java new file mode 100644 index 0000000..9756abe --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/OrderController.java @@ -0,0 +1,64 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.order.service.OrderService; +import com.edgareldy.springjdbctutorial.ws.converter.OrderConverter; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import com.edgareldy.springjdbctutorial.ws.payload.order.OrderRequest; +import com.edgareldy.springjdbctutorial.ws.payload.order.OrderResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points to read and place orders. No business logic. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@RestController +@RequestMapping("/api/v1/orders") +public class OrderController { + + private final OrderService orderService; + + public OrderController(OrderService orderService) { + this.orderService = orderService; + } + + @GetMapping + @PreAuthorize("hasPermission('ORDER','READ')") + public ApiResponse> list(@RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, + @RequestParam(required = false) Long customerId, + @RequestParam(required = false) Long productId) { + return ApiResponse.success( + OrderConverter.toPageResponse(orderService.list(page, size, customerId, productId)), "Orders"); + } + + @GetMapping("/{id}") + @PreAuthorize("hasPermission('ORDER','READ')") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.success(OrderConverter.toResponse(orderService.get(id)), "Order"); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasPermission('ORDER','WRITE')") + public ApiResponse create(@Valid @RequestBody OrderRequest request) { + return ApiResponse.success(OrderConverter.toResponse(orderService.create(OrderConverter.toDto(request))), + "Order created"); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/OrderConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/OrderConverter.java new file mode 100644 index 0000000..6e1d4d6 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/OrderConverter.java @@ -0,0 +1,45 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import com.edgareldy.springjdbctutorial.ws.payload.order.OrderRequest; +import com.edgareldy.springjdbctutorial.ws.payload.order.OrderResponse; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts between the core OrderDto and the order HTTP payloads. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public final class OrderConverter { + + private OrderConverter() { + } + + public static OrderDto toDto(OrderRequest request) { + OrderDto dto = new OrderDto(); + dto.setCustomerId(request.getCustomerId()); + dto.setProductId(request.getProductId()); + dto.setQuantity(request.getQuantity()); + return dto; + } + + public static OrderResponse toResponse(OrderDto dto) { + return new OrderResponse(dto.getId(), dto.getCustomerId(), dto.getProductId(), + dto.getQuantity(), dto.getTotal()); + } + + public static PageResponse toPageResponse(PageDto page) { + List content = new ArrayList<>(); + for (OrderDto dto : page.getContent()) { + content.add(toResponse(dto)); + } + return PageResponse.of(content, page.getPage(), page.getSize(), page.getTotalElements()); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderRequest.java new file mode 100644 index 0000000..858a46e --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderRequest.java @@ -0,0 +1,83 @@ +package com.edgareldy.springjdbctutorial.ws.payload.order; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.util.Objects; + +/** + * Body of the create order call. The total is never sent, the server computes it. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderRequest { + + @NotNull + private Long customerId; + + @NotNull + private Long productId; + + @NotNull + @Min(1) + @Max(1000000) + private Integer quantity; + + public OrderRequest() { + } + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + OrderRequest that = (OrderRequest) o; + return Objects.equals(customerId, that.customerId) && Objects.equals(productId, that.productId) + && Objects.equals(quantity, that.quantity); + } + + @Override + public int hashCode() { + return Objects.hash(customerId, productId, quantity); + } + + @Override + public String toString() { + return "OrderRequest{" + + "customerId=" + customerId + + ", productId=" + productId + + ", quantity=" + quantity + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderResponse.java new file mode 100644 index 0000000..a122289 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderResponse.java @@ -0,0 +1,105 @@ +package com.edgareldy.springjdbctutorial.ws.payload.order; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * An order as returned by the API. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderResponse { + + private Long id; + + private Long customerId; + + private Long productId; + + private int quantity; + + private BigDecimal total; + + public OrderResponse() { + } + + public OrderResponse(Long id, Long customerId, Long productId, int quantity, BigDecimal total) { + this.id = id; + this.customerId = customerId; + this.productId = productId; + this.quantity = quantity; + this.total = total; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public int getQuantity() { + return quantity; + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } + + public BigDecimal getTotal() { + return total; + } + + public void setTotal(BigDecimal total) { + this.total = total; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + OrderResponse that = (OrderResponse) o; + return quantity == that.quantity && Objects.equals(id, that.id) && Objects.equals(customerId, that.customerId) + && Objects.equals(productId, that.productId) && Objects.equals(total, that.total); + } + + @Override + public int hashCode() { + return Objects.hash(id, customerId, productId, quantity, total); + } + + @Override + public String toString() { + return "OrderResponse{" + + "id=" + id + + ", customerId=" + customerId + + ", productId=" + productId + + ", quantity=" + quantity + + ", total=" + total + + "}"; + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/OrderFlowIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/OrderFlowIntegrationTest.java new file mode 100644 index 0000000..a63ed35 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/OrderFlowIntegrationTest.java @@ -0,0 +1,368 @@ +package com.edgareldy.springjdbctutorial.ws.integration; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport; +import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport.TestUser; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * End-to-end order flows through the REAL stack (real WebMvcConfig, real services, real PostgreSQL migrated by + * Flyway, real logins): the total computed from the product price at creation, the filters and pagination, the + * 404 and 422 refusals, the deletes of a customer or a product refused while an order references them, and the + * permission enforcement. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as CustomerFlowIntegrationTest: the whole application context on the shared Testcontainers database. +// Customers, categories and products are created through their own REAL endpoints with an admin, named with an +// it- prefix; tearDown() removes the orders first (they reference the others), then the rest, then the +// users and roles of RbacTestSupport. +@SpringJUnitWebConfig(WebMvcConfig.class) +class OrderFlowIntegrationTest { + + private static final String PREFIX = "it-"; + private static final String URL = "/api/v1/orders"; + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private WebApplicationContext context; + @Autowired + private JdbcTemplate jdbcTemplate; + + private MockMvc mockMvc; + private RbacTestSupport support; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + support = new RbacTestSupport(mockMvc, jdbcTemplate); + } + + @AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM orders WHERE customer_id IN (SELECT id FROM customers WHERE first_name LIKE ?) " + + "OR product_id IN (SELECT id FROM products WHERE product_name LIKE ?)", PREFIX + "%", PREFIX + "%"); + jdbcTemplate.update("DELETE FROM customers WHERE first_name LIKE ?", PREFIX + "%"); + jdbcTemplate.update("DELETE FROM products WHERE product_name LIKE ?", PREFIX + "%"); + jdbcTemplate.update("DELETE FROM categories WHERE category_name LIKE ?", PREFIX + "%"); + support.close(); + } + + private static String unique() { + return PREFIX + UUID.randomUUID().toString().substring(0, 8); + } + + private static long idOf(MvcResult result) throws Exception { + return assertSuccess(result, 201).get("data").get("id").asLong(); + } + + private long customer(String jwt) throws Exception { + return idOf(support.post(jwt, "/api/v1/customers", "{\"firstName\":\"" + unique() + "\",\"lastName\":\"Martin\"}")); + } + + private long category(String jwt) throws Exception { + return idOf(support.post(jwt, "/api/v1/categories", "{\"categoryName\":\"" + unique() + "\"}")); + } + + private long product(String jwt, long categoryId, String unitPrice) throws Exception { + return idOf(support.post(jwt, "/api/v1/products", "{\"categoryId\":" + categoryId + ",\"productName\":\"" + + unique() + "\",\"unitPrice\":" + unitPrice + "}")); + } + + private static String orderBody(Object customerId, Object productId, Object quantity) { + return "{\"customerId\":" + customerId + ",\"productId\":" + productId + ",\"quantity\":" + quantity + "}"; + } + + private long order(String jwt, long customerId, long productId, int quantity) throws Exception { + return idOf(support.post(jwt, URL, orderBody(customerId, productId, quantity))); + } + + private long count(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Long.class, args); + } + + private static List ids(JsonNode page) { + List ids = new ArrayList<>(); + for (JsonNode order : page.get("data").get("content")) { + ids.add(order.get("id").asLong()); + } + return ids; + } + + @Test + void _01_ShouldComputeTheTotalFromTheProductPrice_WhenAdminCreatesAnOrder() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "19.99"); + + JsonNode created = assertSuccess(support.post(jwt, URL, orderBody(customerId, productId, 3)), 201); + + assertThat(created.get("message").asText()).isEqualTo("Order created"); + assertThat(created.get("data").get("customerId").asLong()).isEqualTo(customerId); + assertThat(created.get("data").get("productId").asLong()).isEqualTo(productId); + assertThat(created.get("data").get("quantity").asInt()).isEqualTo(3); + assertThat(created.get("data").get("total").decimalValue()).isEqualByComparingTo("59.97"); + long id = created.get("data").get("id").asLong(); + JsonNode read = assertSuccess(support.get(jwt, URL + "/" + id), 200).get("data"); + assertThat(read.get("total").decimalValue()).isEqualByComparingTo("59.97"); + assertThat(read.get("quantity").asInt()).isEqualTo(3); + assertThat(count("SELECT COUNT(*) FROM orders WHERE id = ? AND total = 59.97", id)).isEqualTo(1L); + } + + @Test + void _02_ShouldKeepTheTotal_WhenTheProductPriceIsUpdatedLater() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long categoryId = category(jwt); + long productId = product(jwt, categoryId, "10.00"); + long id = order(jwt, customerId, productId, 2); + + assertSuccess(support.put(jwt, "/api/v1/products/" + productId, + "{\"categoryId\":" + categoryId + ",\"productName\":\"" + unique() + "\",\"unitPrice\":99.00}"), 200); + + JsonNode read = assertSuccess(support.get(jwt, URL + "/" + id), 200).get("data"); + assertThat(read.get("total").decimalValue()).isEqualByComparingTo("20.00"); + long next = order(jwt, customerId, productId, 2); + assertThat(assertSuccess(support.get(jwt, URL + "/" + next), 200).get("data").get("total").decimalValue()) + .isEqualByComparingTo("198.00"); + } + + @Test + void _03_ShouldFilterAndPaginate_WhenListingWithCustomerIdAndProductId() throws Exception { + String jwt = support.createAdmin().getJwt(); + long c1 = customer(jwt); + long c2 = customer(jwt); + long categoryId = category(jwt); + long p1 = product(jwt, categoryId, "1.00"); + long p2 = product(jwt, categoryId, "2.00"); + long o1 = order(jwt, c1, p1, 1); + long o2 = order(jwt, c1, p2, 1); + long o3 = order(jwt, c2, p1, 1); + long o4 = order(jwt, c1, p1, 2); + + assertThat(ids(assertSuccess(support.get(jwt, URL + "?customerId=" + c1), 200))).containsExactly(o1, o2, o4); + assertThat(ids(assertSuccess(support.get(jwt, URL + "?productId=" + p1), 200))).containsExactly(o1, o3, o4); + assertThat(ids(assertSuccess(support.get(jwt, URL + "?customerId=" + c1 + "&productId=" + p1), 200))) + .containsExactly(o1, o4); + assertThat(ids(assertSuccess(support.get(jwt, URL + "?customerId=" + c2 + "&productId=" + p2), 200))).isEmpty(); + + JsonNode second = assertSuccess(support.get(jwt, URL + "?customerId=" + c1 + "&size=2&page=1"), 200); + assertThat(ids(second)).containsExactly(o4); + assertThat(second.get("data").get("totalElements").asLong()).isEqualTo(3L); + assertThat(second.get("data").get("totalPages").asInt()).isEqualTo(2); + } + + @Test + void _04_ShouldReturn404AndStoreNothing_WhenCustomerOrProductDoesNotExist() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "5.00"); + + JsonNode noCustomer = assertError(support.post(jwt, URL, orderBody(999999999L, productId, 1)), 404); + JsonNode noProduct = assertError(support.post(jwt, URL, orderBody(customerId, 999999999L, 1)), 404); + + assertThat(noCustomer.get("message").asText()).isEqualTo("Customer not found: 999999999"); + assertThat(noProduct.get("message").asText()).isEqualTo("Product not found: 999999999"); + assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ? OR product_id = ?", customerId, productId)) + .isZero(); + } + + @Test + void _05_ShouldReturn400AndStoreNothing_WhenBodyIsInvalid() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "5.00"); + + for (Object quantity : List.of(0, -1, 1000001)) { + JsonNode json = assertError(support.post(jwt, URL, orderBody(customerId, productId, quantity)), 400); + assertThat(json.get("message").asText()).contains("quantity: "); + } + JsonNode missing = assertError(support.post(jwt, URL, "{\"customerId\":" + customerId + ",\"productId\":" + + productId + "}"), 400); + JsonNode noIds = assertError(support.post(jwt, URL, "{\"quantity\":1}"), 400); + + assertThat(missing.get("message").asText()).contains("quantity: "); + assertThat(noIds.get("message").asText()).contains("customerId: ").contains("productId: "); + assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ?", customerId)).isZero(); + } + + @Test + void _06_ShouldReturn422AndStoreNothing_WhenTheTotalExceedsTheColumnLimit() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "9999999999.99"); + + JsonNode json = assertError(support.post(jwt, URL, orderBody(customerId, productId, 1000000)), 422); + + assertThat(json.get("message").asText()).isEqualTo("Order total must not exceed 999999999999.99"); + assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ?", customerId)).isZero(); + // 99 x 9999999999.99 is still below the limit and goes through + assertSuccess(support.post(jwt, URL, orderBody(customerId, productId, 99)), 201); + } + + @Test + void _07_ShouldRefuseTheCustomerDeleteWith422UntilTheOrderIsRemoved_WhenAnOrderReferencesTheCustomer() + throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "5.00"); + long orderId = order(jwt, customerId, productId, 1); + + JsonNode refused = assertError(support.delete(jwt, "/api/v1/customers/" + customerId), 422); + + assertThat(refused.get("message").asText()).isEqualTo("Customer is referenced by orders"); + assertSuccess(support.get(jwt, "/api/v1/customers/" + customerId), 200); + jdbcTemplate.update("DELETE FROM orders WHERE id = ?", orderId); + assertSuccess(support.delete(jwt, "/api/v1/customers/" + customerId), 200); + assertError(support.get(jwt, "/api/v1/customers/" + customerId), 404); + } + + @Test + void _08_ShouldRefuseTheProductDeleteWith422UntilTheOrderIsRemoved_WhenAnOrderReferencesTheProduct() + throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "5.00"); + long orderId = order(jwt, customerId, productId, 1); + + JsonNode refused = assertError(support.delete(jwt, "/api/v1/products/" + productId), 422); + + assertThat(refused.get("message").asText()).isEqualTo("Product is referenced by orders"); + assertSuccess(support.get(jwt, "/api/v1/products/" + productId), 200); + jdbcTemplate.update("DELETE FROM orders WHERE id = ?", orderId); + assertSuccess(support.delete(jwt, "/api/v1/products/" + productId), 200); + assertError(support.get(jwt, "/api/v1/products/" + productId), 404); + } + + @Test + void _09_ShouldReturn404_WhenOrderDoesNotExist() throws Exception { + String jwt = support.createAdmin().getJwt(); + + JsonNode json = assertError(support.get(jwt, URL + "/999999999"), 404); + + assertThat(json.get("message").asText()).isEqualTo("Order not found: 999999999"); + } + + @Test + void _10_ShouldReturn400NamingTheParameter_WhenPageOrSizeIsOutOfBounds() throws Exception { + String jwt = support.createAdmin().getJwt(); + + JsonNode page = assertError(support.get(jwt, URL + "?page=-1"), 400); + JsonNode zero = assertError(support.get(jwt, URL + "?size=0"), 400); + JsonNode big = assertError(support.get(jwt, URL + "?size=101"), 400); + + assertThat(page.get("message").asText()).contains("page: "); + assertThat(zero.get("message").asText()).contains("size: "); + assertThat(big.get("message").asText()).contains("size: "); + assertSuccess(support.get(jwt, URL + "?size=1"), 200); + assertSuccess(support.get(jwt, URL + "?size=100"), 200); + } + + @Test + void _11_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + order(jwt, customerId, product(jwt, category(jwt), "1.00"), 1); + + JsonNode data = assertSuccess(support.get(jwt, URL + "?customerId=" + customerId + "&page=100000&size=10"), 200) + .get("data"); + + assertThat(data.has("content")).isTrue(); + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("page").asInt()).isEqualTo(100000); + assertThat(data.get("totalElements").asLong()).isEqualTo(1L); + } + + @Test + void _12_ShouldReturn401_WhenNoTokenIsSent() throws Exception { + assertError(support.get(null, URL), 401); + assertError(support.get(null, URL + "/1"), 401); + assertError(support.post(null, URL, orderBody(1, 1, 1)), 401); + } + + @Test + void _13_ShouldReturn403AndStoreNothing_WhenUserLacksTheWritePermission() throws Exception { + TestUser simple = support.createSimpleUser(); + TestUser readOnly = support.createUser(true, false, support.createRole("ORDER:READ")); + String admin = support.createAdmin().getJwt(); + long customerId = customer(admin); + long productId = product(admin, category(admin), "5.00"); + long id = order(admin, customerId, productId, 1); + + for (TestUser user : List.of(simple, readOnly)) { + assertError(support.post(user.getJwt(), URL, orderBody(customerId, productId, 1)), 403); + } + + assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ?", customerId)).isEqualTo(1L); + // The simple user has no read permission either, the read-only user does + assertError(support.get(simple.getJwt(), URL), 403); + assertError(support.get(simple.getJwt(), URL + "/" + id), 403); + assertSuccess(support.get(readOnly.getJwt(), URL + "/" + id), 200); + assertSuccess(support.get(readOnly.getJwt(), URL + "?customerId=" + customerId), 200); + } + + @Test + void _14_ShouldCreateButNotRead_WhenUserHoldsOnlyOrderWrite() throws Exception { + String admin = support.createAdmin().getJwt(); + long customerId = customer(admin); + long productId = product(admin, category(admin), "5.00"); + TestUser writer = support.createUser(true, false, support.createRole("ORDER:WRITE")); + + long id = order(writer.getJwt(), customerId, productId, 2); + + assertError(support.get(writer.getJwt(), URL + "/" + id), 403); + assertError(support.get(writer.getJwt(), URL), 403); + } + + @Test + void _15_ShouldReturn405_WhenAnOrderIsUpdatedOrDeleted() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long id = order(jwt, customerId, product(jwt, category(jwt), "5.00"), 1); + + assertError(support.put(jwt, URL + "/" + id, orderBody(customerId, 1, 1)), 405); + assertError(support.delete(jwt, URL + "/" + id), 405); + assertSuccess(support.get(jwt, URL + "/" + id), 200); + } + + // Non-regression: Jackson used to truncate a decimal quantity such as 1.5 to 1 and create the order + @Test + void _16_ShouldReturn400AndStoreNothing_WhenQuantityIsADecimal() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "5.00"); + + assertError(support.post(jwt, URL, orderBody(customerId, productId, "1.5")), 400); + + assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ?", customerId)).isZero(); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractOrderMvcTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractOrderMvcTest.java new file mode 100644 index 0000000..92b7396 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractOrderMvcTest.java @@ -0,0 +1,92 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static org.mockito.Mockito.reset; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.order.service.OrderService; +import com.edgareldy.springjdbctutorial.ws.support.JwtTestSupport; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * Common base of the order controller tests: builds MockMvc on the shared OrderMvcTestConfig context, resets + * the mocked services before each test and offers request helpers (signed tokens, JSON bodies). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// MockMvc + springSecurity() runs the real filter chain in front of the DispatcherServlet (same as +// AbstractCatalogMvcTest): the JWT filter and @PreAuthorize are exercised, only the services are mocked. +@SpringJUnitWebConfig(OrderMvcTestConfig.class) +abstract class AbstractOrderMvcTest { + + @Autowired + private WebApplicationContext context; + @Autowired + protected OrderService orderService; + @Autowired + protected AuthService authService; + + protected MockMvc mockMvc; + + @BeforeEach + void setUpMockMvc() { + reset(orderService, authService); + mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + } + + /** A signed dev-key JWT carrying exactly these permissions. */ + protected static String token(String... permissions) { + return JwtTestSupport.tokenWithPermissions(5L, List.of(permissions)); + } + + /** A signed dev-key JWT carrying every seeded permission except the given one. */ + protected static String tokenWithEverythingExcept(String permission) { + return JwtTestSupport.tokenWithPermissions(5L, + JwtTestSupport.ALL_PERMISSIONS.stream().filter(p -> !p.equals(permission)).toList()); + } + + protected static String bearer(String jwt) { + return "Bearer " + jwt; + } + + protected MvcResult call(MockHttpServletRequestBuilder request) throws Exception { + return mockMvc.perform(request).andReturn(); + } + + protected static MockHttpServletRequestBuilder getWith(String url, String jwt) { + return get(url).header("Authorization", bearer(jwt)); + } + + protected static MockHttpServletRequestBuilder deleteWith(String url, String jwt) { + return delete(url).header("Authorization", bearer(jwt)); + } + + protected static MockHttpServletRequestBuilder patchWith(String url, String jwt) { + return patch(url).header("Authorization", bearer(jwt)); + } + + protected static MockHttpServletRequestBuilder postJson(String url, String jwt, String body) { + return post(url).header("Authorization", bearer(jwt)).contentType(MediaType.APPLICATION_JSON).content(body); + } + + protected static MockHttpServletRequestBuilder putJson(String url, String jwt, String body) { + return put(url).header("Authorization", bearer(jwt)).contentType(MediaType.APPLICATION_JSON).content(body); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderControllerTest.java new file mode 100644 index 0000000..61030c9 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderControllerTest.java @@ -0,0 +1,279 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +/** + * MockMvc tests of OrderController with OrderService mocked but the real security and validation: statuses, ApiResponse shape, filters, page/size bounds, per-field body validation, the 404/422 refusals and the missing PUT/DELETE. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class OrderControllerTest extends AbstractOrderMvcTest { + + private static final String READ = "ORDER:READ"; + private static final String WRITE = "ORDER:WRITE"; + private static final String URL = "/api/v1/orders"; + private static final OrderDto ORDER = new OrderDto(1L, 2L, 3L, 3, new BigDecimal("59.97")); + + private static String body(String customerId, String productId, String quantity) { + return "{\"customerId\":" + customerId + ",\"productId\":" + productId + ",\"quantity\":" + quantity + "}"; + } + + // ---------------------------------------------------------------- GET /orders + + @Test + void _01_ShouldReturn200WithPageAndDefaults_WhenTokenHoldsOrderRead() throws Exception { + when(orderService.list(0, 20, null, null)).thenReturn(new PageDto<>(List.of(ORDER), 0, 20, 1)); + + JsonNode data = assertSuccess(call(getWith(URL, token(READ))), 200).get("data"); + + JsonNode first = data.get("content").get(0); + assertThat(first.get("id").asLong()).isEqualTo(1L); + assertThat(first.get("customerId").asLong()).isEqualTo(2L); + assertThat(first.get("productId").asLong()).isEqualTo(3L); + assertThat(first.get("quantity").asInt()).isEqualTo(3); + assertThat(first.get("total").decimalValue()).isEqualByComparingTo("59.97"); + assertThat(data.get("page").asInt()).isZero(); + assertThat(data.get("size").asInt()).isEqualTo(20); + assertThat(data.get("totalElements").asLong()).isEqualTo(1L); + assertThat(data.get("totalPages").asInt()).isEqualTo(1); + } + + @Test + void _02_ShouldPassPageSizeAndBothFiltersToTheService_WhenTheyAreGiven() throws Exception { + when(orderService.list(2, 5, 7L, 8L)).thenReturn(new PageDto<>(List.of(), 2, 5, 11)); + + JsonNode data = assertSuccess(call(getWith(URL + "?page=2&size=5&customerId=7&productId=8", token(READ))), 200) + .get("data"); + + assertThat(data.get("page").asInt()).isEqualTo(2); + verify(orderService).list(2, 5, 7L, 8L); + } + + @Test + void _03_ShouldPassOnlyTheCustomerFilter_WhenProductIdIsAbsent() throws Exception { + when(orderService.list(0, 20, 7L, null)).thenReturn(new PageDto<>(List.of(ORDER), 0, 20, 1)); + + assertSuccess(call(getWith(URL + "?customerId=7", token(READ))), 200); + + verify(orderService).list(0, 20, 7L, null); + } + + @Test + void _04_ShouldPassOnlyTheProductFilter_WhenCustomerIdIsAbsent() throws Exception { + when(orderService.list(0, 20, null, 8L)).thenReturn(new PageDto<>(List.of(ORDER), 0, 20, 1)); + + assertSuccess(call(getWith(URL + "?productId=8", token(READ))), 200); + + verify(orderService).list(0, 20, null, 8L); + } + + @Test + void _05_ShouldReturn400_WhenAFilterIsNotANumber() throws Exception { + assertError(call(getWith(URL + "?customerId=abc", token(READ))), 400); + assertError(call(getWith(URL + "?productId=abc", token(READ))), 400); + verifyNoInteractions(orderService); + } + + @Test + void _06_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + when(orderService.list(99, 10, null, null)).thenReturn(new PageDto<>(List.of(), 99, 10, 3)); + + JsonNode data = assertSuccess(call(getWith(URL + "?page=99&size=10", token(READ))), 200).get("data"); + + assertThat(data.has("content")).isTrue(); + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("totalElements").asLong()).isEqualTo(3L); + } + + @Test + void _07_ShouldReturn400NamingPage_WhenPageIsNegative() throws Exception { + JsonNode json = assertError(call(getWith(URL + "?page=-1", token(READ))), 400); + + assertThat(json.get("message").asText()).contains("page: ").doesNotContain("size: "); + verifyNoInteractions(orderService); + } + + @Test + void _08_ShouldReturn400NamingSize_WhenSizeIsZeroOrAbove100() throws Exception { + JsonNode zero = assertError(call(getWith(URL + "?size=0", token(READ))), 400); + JsonNode big = assertError(call(getWith(URL + "?size=101", token(READ))), 400); + + assertThat(zero.get("message").asText()).contains("size: ").doesNotContain("page: "); + assertThat(big.get("message").asText()).contains("size: "); + verifyNoInteractions(orderService); + } + + @Test + void _09_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() throws Exception { + when(orderService.list(0, 1, null, null)).thenReturn(new PageDto<>(List.of(), 0, 1, 0)); + when(orderService.list(0, 100, null, null)).thenReturn(new PageDto<>(List.of(), 0, 100, 0)); + + assertSuccess(call(getWith(URL + "?size=1", token(READ))), 200); + assertSuccess(call(getWith(URL + "?size=100", token(READ))), 200); + } + + // ---------------------------------------------------------------- GET /orders/{id} + + @Test + void _10_ShouldReturn200WithTheOrder_WhenOrderExists() throws Exception { + when(orderService.get(1L)).thenReturn(ORDER); + + JsonNode json = assertSuccess(call(getWith(URL + "/1", token(READ))), 200); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(1L); + assertThat(json.get("data").get("total").decimalValue()).isEqualByComparingTo("59.97"); + assertThat(json.get("message").asText()).isEqualTo("Order"); + } + + @Test + void _11_ShouldReturn404ApiResponse_WhenOrderDoesNotExist() throws Exception { + when(orderService.get(99L)).thenThrow(new ResourceNotFoundException("Order not found: 99")); + + JsonNode json = assertError(call(getWith(URL + "/99", token(READ))), 404); + + assertThat(json.get("message").asText()).isEqualTo("Order not found: 99"); + } + + @Test + void _12_ShouldReturn400_WhenOrderIdIsNotANumber() throws Exception { + assertError(call(getWith(URL + "/abc", token(READ))), 400); + verifyNoInteractions(orderService); + } + + // ---------------------------------------------------------------- POST /orders + + @Test + void _13_ShouldReturn201WithCreatedOrder_WhenBodyIsValid() throws Exception { + when(orderService.create(any(OrderDto.class))).thenReturn(ORDER); + + JsonNode json = assertSuccess(call(postJson(URL, token(WRITE), body("2", "3", "3"))), 201); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(1L); + assertThat(json.get("data").get("total").decimalValue()).isEqualByComparingTo("59.97"); + assertThat(json.get("message").asText()).isEqualTo("Order created"); + ArgumentCaptor sent = ArgumentCaptor.forClass(OrderDto.class); + verify(orderService).create(sent.capture()); + assertThat(sent.getValue()).isEqualTo(new OrderDto(null, 2L, 3L, 3, null)); + } + + @Test + void _14_ShouldIgnoreAClientSuppliedTotal_WhenBodyCarriesOne() throws Exception { + when(orderService.create(any(OrderDto.class))).thenReturn(ORDER); + + assertSuccess(call(postJson(URL, token(WRITE), + "{\"customerId\":2,\"productId\":3,\"quantity\":3,\"total\":0.01,\"id\":77}")), 201); + + ArgumentCaptor sent = ArgumentCaptor.forClass(OrderDto.class); + verify(orderService).create(sent.capture()); + assertThat(sent.getValue().getTotal()).isNull(); + assertThat(sent.getValue().getId()).isNull(); + } + + @Test + void _15_ShouldReturn400NamingTheField_WhenCustomerIdOrProductIdIsNull() throws Exception { + JsonNode customer = assertError(call(postJson(URL, token(WRITE), body("null", "3", "1"))), 400); + JsonNode product = assertError(call(postJson(URL, token(WRITE), body("2", "null", "1"))), 400); + JsonNode missing = assertError(call(postJson(URL, token(WRITE), "{\"quantity\":1}")), 400); + + assertThat(customer.get("message").asText()).contains("customerId: ").doesNotContain("productId: "); + assertThat(product.get("message").asText()).contains("productId: ").doesNotContain("customerId: "); + assertThat(missing.get("message").asText()).contains("customerId: ").contains("productId: "); + verifyNoInteractions(orderService); + } + + @Test + void _16_ShouldReturn400NamingQuantity_WhenQuantityIsMissingZeroOrTooLarge() throws Exception { + JsonNode missing = assertError(call(postJson(URL, token(WRITE), "{\"customerId\":2,\"productId\":3}")), 400); + JsonNode zero = assertError(call(postJson(URL, token(WRITE), body("2", "3", "0"))), 400); + JsonNode negative = assertError(call(postJson(URL, token(WRITE), body("2", "3", "-1"))), 400); + JsonNode big = assertError(call(postJson(URL, token(WRITE), body("2", "3", "1000001"))), 400); + + for (JsonNode json : List.of(missing, zero, negative, big)) { + assertThat(json.get("message").asText()).contains("quantity: "); + } + verifyNoInteractions(orderService); + } + + @Test + void _17_ShouldReturn201_WhenQuantityIsOnItsLimits() throws Exception { + when(orderService.create(any(OrderDto.class))).thenReturn(ORDER); + + assertSuccess(call(postJson(URL, token(WRITE), body("2", "3", "1"))), 201); + assertSuccess(call(postJson(URL, token(WRITE), body("2", "3", "1000000"))), 201); + } + + @Test + void _18_ShouldReturn400_WhenBodyIsMalformedJsonOrHasTheWrongTypes() throws Exception { + assertError(call(postJson(URL, token(WRITE), "{\"customerId\":")), 400); + assertError(call(postJson(URL, token(WRITE), body("\"abc\"", "3", "1"))), 400); + verifyNoInteractions(orderService); + } + + @Test + void _19_ShouldReturn404_WhenTheServiceReportsAnUnknownCustomer() throws Exception { + when(orderService.create(any(OrderDto.class))).thenThrow(new ResourceNotFoundException("Customer not found: 2")); + + JsonNode json = assertError(call(postJson(URL, token(WRITE), body("2", "3", "1"))), 404); + + assertThat(json.get("message").asText()).isEqualTo("Customer not found: 2"); + } + + @Test + void _20_ShouldReturn404_WhenTheServiceReportsAnUnknownProduct() throws Exception { + when(orderService.create(any(OrderDto.class))).thenThrow(new ResourceNotFoundException("Product not found: 3")); + + JsonNode json = assertError(call(postJson(URL, token(WRITE), body("2", "3", "1"))), 404); + + assertThat(json.get("message").asText()).isEqualTo("Product not found: 3"); + } + + @Test + void _21_ShouldReturn422_WhenTheServiceRefusesTheTotal() throws Exception { + when(orderService.create(any(OrderDto.class))) + .thenThrow(new BusinessRuleException("Order total must not exceed 999999999999.99")); + + JsonNode json = assertError(call(postJson(URL, token(WRITE), body("2", "3", "1000000"))), 422); + + assertThat(json.get("message").asText()).isEqualTo("Order total must not exceed 999999999999.99"); + } + + @Test + void _22_ShouldReturn415_WhenContentTypeIsNotJson() throws Exception { + assertError(call(MockMvcRequestBuilders.post(URL) + .header("Authorization", bearer(token(WRITE))).contentType(MediaType.TEXT_PLAIN).content("x")), 415); + verifyNoInteractions(orderService); + } + + // ---------------------------------------------------------------- orders are immutable + + @Test + void _23_ShouldReturn405ApiResponse_WhenPutDeleteOrPatchIsUsed() throws Exception { + assertError(call(putJson(URL + "/1", token(WRITE), body("2", "3", "1"))), 405); + assertError(call(deleteWith(URL + "/1", token(WRITE))), 405); + assertError(call(patchWith(URL + "/1", token(WRITE))), 405); + assertError(call(deleteWith(URL, token(WRITE))), 405); + verifyNoInteractions(orderService); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderEndpointSecurityTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderEndpointSecurityTest.java new file mode 100644 index 0000000..5a28639 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderEndpointSecurityTest.java @@ -0,0 +1,111 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigDecimal; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +/** + * Permission matrix of the 3 order endpoints of the README table, checked through the real filter chain and method security: no token or a revoked token gives 401, a token holding every permission EXCEPT the required one (or none at all) gives 403, all as ApiResponse errors and without reaching the (mocked) service; the right permission alone gives 2xx. + *

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

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as CatalogMvcTestConfig, kept in ws.mvc and never in ws.controller so WebMvcConfig's component scan +// cannot pick it up. +@Configuration +@EnableWebMvc +@Import(SecurityConfig.class) +public class OrderMvcTestConfig implements WebMvcConfigurer { + + private final WebMvcConfig real = new WebMvcConfig(); + + @Bean + Clock clock() { + return Clock.systemUTC(); + } + + @Bean + ObjectMapper objectMapper() { + return real.objectMapper(); + } + + @Bean + AuthService authService() { + return mock(AuthService.class); + } + + @Bean + OrderService orderService() { + return mock(OrderService.class); + } + + @Bean + OrderController orderController(OrderService orderService) { + return new OrderController(orderService); + } + + @Bean + GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Override + public void extendMessageConverters(List> converters) { + real.extendMessageConverters(converters); + } + + @Override + public Validator getValidator() { + return real.getValidator(); + } +}