diff --git a/README.md b/README.md
index 7085ec2..6cd8409 100644
--- a/README.md
+++ b/README.md
@@ -421,10 +421,10 @@ Full CRUD for users, roles, and permissions, still inside `core/auth`. Assignmen
### Tasks
-- [ ] `core/customer`: `Customer` entity (`@Table("customers")`) and `CustomerDto`, `CustomerRowMapper` (entity) plus `CustomerMapper` (entity ↔ dto), `CustomerDao` + impl, `CustomerService` + impl, `DaoConfig`/`ServiceConfig`
-- [ ] `ws`: `CustomerController`, `CustomerConverter`
-- [ ] `WebMvcConfig` updated to import `core/customer`'s config
-- [ ] Tests at all five layers, `CustomerDao` with its own fixture dataset
+- [x] `core/customer`: `Customer` entity (`@Table("customers")`) and `CustomerDto`, `CustomerRowMapper` (entity) plus `CustomerMapper` (entity ↔ dto), `CustomerDao` + impl, `CustomerService` + impl, `DaoConfig`/`ServiceConfig`
+- [x] `ws`: `CustomerController`, `CustomerConverter`
+- [x] `WebMvcConfig` updated to import `core/customer`'s config
+- [x] Tests at all five layers, `CustomerDao` with its own fixture dataset
## feature/order
diff --git a/core/customer/pom.xml b/core/customer/pom.xml
index 227da5e..4b46f4f 100644
--- a/core/customer/pom.xml
+++ b/core/customer/pom.xml
@@ -19,5 +19,22 @@
com.edgareldy.springjdbctutorial
common
+
+
+ com.edgareldy.springjdbctutorial
+ common
+ test-jar
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+ org.testcontainers
+ postgresql
+ test
+
diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/DaoConfig.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/DaoConfig.java
new file mode 100644
index 0000000..93ad4f5
--- /dev/null
+++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/DaoConfig.java
@@ -0,0 +1,24 @@
+package com.edgareldy.springjdbctutorial.core.customer.config;
+
+import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao;
+import com.edgareldy.springjdbctutorial.core.customer.dao.impl.CustomerDaoImpl;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+/**
+ * Declares the DAO beans of the customer 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 CustomerDao customerDao(JdbcTemplate jdbcTemplate) {
+ return new CustomerDaoImpl(jdbcTemplate);
+ }
+}
diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/ServiceConfig.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/ServiceConfig.java
new file mode 100644
index 0000000..96f4f0c
--- /dev/null
+++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/ServiceConfig.java
@@ -0,0 +1,26 @@
+package com.edgareldy.springjdbctutorial.core.customer.config;
+
+import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao;
+import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService;
+import com.edgareldy.springjdbctutorial.core.customer.service.impl.CustomerServiceImpl;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+
+/**
+ * Declares the service beans of the customer module. Imports its DaoConfig so importing this class alone is enough.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+@Configuration
+@Import(DaoConfig.class)
+public class ServiceConfig {
+
+ @Bean
+ public CustomerService customerService(CustomerDao customerDao) {
+ return new CustomerServiceImpl(customerDao);
+ }
+}
diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/CustomerDao.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/CustomerDao.java
new file mode 100644
index 0000000..6245a3e
--- /dev/null
+++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/CustomerDao.java
@@ -0,0 +1,32 @@
+package com.edgareldy.springjdbctutorial.core.customer.dao;
+
+import com.edgareldy.springjdbctutorial.core.customer.entity.Customer;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Data access for customers. Entity types only.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public interface CustomerDao {
+
+ /** One page of customers ordered by id. */
+ List findPage(int page, int size);
+
+ long countAll();
+
+ Optional findById(Long id);
+
+ /** Inserts the customer and returns it with its generated id. */
+ Customer insert(Customer customer);
+
+ /** Returns the number of rows affected, 0 when the customer no longer exists. */
+ int update(Customer customer);
+
+ void delete(Long id);
+}
diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImpl.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImpl.java
new file mode 100644
index 0000000..957a1ad
--- /dev/null
+++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImpl.java
@@ -0,0 +1,70 @@
+package com.edgareldy.springjdbctutorial.core.customer.dao.impl;
+
+import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao;
+import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao;
+import com.edgareldy.springjdbctutorial.core.customer.entity.Customer;
+import com.edgareldy.springjdbctutorial.core.customer.mapper.CustomerRowMapper;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * JdbcTemplate implementation of CustomerDao. 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 CustomerDaoImpl extends AbstractDao implements CustomerDao {
+
+ private static final String COLUMNS = "id, first_name, last_name, telephone, email, address";
+
+ private final CustomerRowMapper rowMapper = new CustomerRowMapper();
+
+ public CustomerDaoImpl(JdbcTemplate jdbcTemplate) {
+ super(jdbcTemplate);
+ }
+
+ @Override
+ public List findPage(int page, int size) {
+ return getJdbcTemplate().query(
+ "SELECT " + COLUMNS + " FROM customers ORDER BY id LIMIT ? OFFSET ?",
+ rowMapper, size, (long) page * size);
+ }
+
+ @Override
+ public long countAll() {
+ return count("SELECT COUNT(*) FROM customers");
+ }
+
+ @Override
+ public Optional findById(Long id) {
+ return findOne("SELECT " + COLUMNS + " FROM customers WHERE id = ?", rowMapper, id);
+ }
+
+ @Override
+ public Customer insert(Customer customer) {
+ long id = insertReturningId(
+ "INSERT INTO customers (first_name, last_name, telephone, email, address) "
+ + "VALUES (?, ?, ?, ?, ?) RETURNING id",
+ customer.getFirstName(), customer.getLastName(), customer.getTelephone(),
+ customer.getEmail(), customer.getAddress());
+ customer.setId(id);
+ return customer;
+ }
+
+ @Override
+ public int update(Customer customer) {
+ return getJdbcTemplate().update(
+ "UPDATE customers SET first_name = ?, last_name = ?, telephone = ?, email = ?, address = ? WHERE id = ?",
+ customer.getFirstName(), customer.getLastName(), customer.getTelephone(),
+ customer.getEmail(), customer.getAddress(), customer.getId());
+ }
+
+ @Override
+ public void delete(Long id) {
+ getJdbcTemplate().update("DELETE FROM customers WHERE id = ?", id);
+ }
+}
diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dto/CustomerDto.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dto/CustomerDto.java
new file mode 100644
index 0000000..3a87492
--- /dev/null
+++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dto/CustomerDto.java
@@ -0,0 +1,117 @@
+package com.edgareldy.springjdbctutorial.core.customer.dto;
+
+import java.util.Objects;
+
+/**
+ * Business shape of a customer.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class CustomerDto {
+
+ private Long id;
+
+ private String firstName;
+
+ private String lastName;
+
+ private String telephone;
+
+ private String email;
+
+ private String address;
+
+ public CustomerDto() {
+ }
+
+ public CustomerDto(Long id, String firstName, String lastName, String telephone, String email, String address) {
+ this.id = id;
+ this.firstName = firstName;
+ this.lastName = lastName;
+ this.telephone = telephone;
+ this.email = email;
+ this.address = address;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public void setFirstName(String firstName) {
+ this.firstName = firstName;
+ }
+
+ public String getLastName() {
+ return lastName;
+ }
+
+ public void setLastName(String lastName) {
+ this.lastName = lastName;
+ }
+
+ public String getTelephone() {
+ return telephone;
+ }
+
+ public void setTelephone(String telephone) {
+ this.telephone = telephone;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ public String getAddress() {
+ return address;
+ }
+
+ public void setAddress(String address) {
+ this.address = address;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ CustomerDto that = (CustomerDto) o;
+ return Objects.equals(id, that.id) && Objects.equals(firstName, that.firstName)
+ && Objects.equals(lastName, that.lastName) && Objects.equals(telephone, that.telephone)
+ && Objects.equals(email, that.email) && Objects.equals(address, that.address);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, firstName, lastName, telephone, email, address);
+ }
+
+ @Override
+ public String toString() {
+ return "CustomerDto{" +
+ "id=" + id +
+ ", firstName=" + firstName +
+ ", lastName=" + lastName +
+ ", telephone=" + telephone +
+ ", email=" + email +
+ ", address=" + address +
+ "}";
+ }
+}
diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/entity/Customer.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/entity/Customer.java
new file mode 100644
index 0000000..0595080
--- /dev/null
+++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/entity/Customer.java
@@ -0,0 +1,128 @@
+package com.edgareldy.springjdbctutorial.core.customer.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.util.Objects;
+
+/**
+ * Row of the customers table. The jakarta.persistence annotations are documentation only, nothing reads them at runtime.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+@Table(name = "customers")
+public class Customer {
+
+ @Id
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "first_name")
+ private String firstName;
+
+ @Column(name = "last_name")
+ private String lastName;
+
+ @Column(name = "telephone")
+ private String telephone;
+
+ @Column(name = "email")
+ private String email;
+
+ @Column(name = "address")
+ private String address;
+
+ public Customer() {
+ }
+
+ public Customer(Long id, String firstName, String lastName, String telephone, String email, String address) {
+ this.id = id;
+ this.firstName = firstName;
+ this.lastName = lastName;
+ this.telephone = telephone;
+ this.email = email;
+ this.address = address;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public void setFirstName(String firstName) {
+ this.firstName = firstName;
+ }
+
+ public String getLastName() {
+ return lastName;
+ }
+
+ public void setLastName(String lastName) {
+ this.lastName = lastName;
+ }
+
+ public String getTelephone() {
+ return telephone;
+ }
+
+ public void setTelephone(String telephone) {
+ this.telephone = telephone;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ public String getAddress() {
+ return address;
+ }
+
+ public void setAddress(String address) {
+ this.address = address;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Customer that = (Customer) o;
+ return Objects.equals(id, that.id) && Objects.equals(firstName, that.firstName)
+ && Objects.equals(lastName, that.lastName) && Objects.equals(telephone, that.telephone)
+ && Objects.equals(email, that.email) && Objects.equals(address, that.address);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, firstName, lastName, telephone, email, address);
+ }
+
+ @Override
+ public String toString() {
+ return "Customer{" +
+ "id=" + id +
+ ", firstName=" + firstName +
+ ", lastName=" + lastName +
+ ", telephone=" + telephone +
+ ", email=" + email +
+ ", address=" + address +
+ "}";
+ }
+}
diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapper.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapper.java
new file mode 100644
index 0000000..e30a623
--- /dev/null
+++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapper.java
@@ -0,0 +1,31 @@
+package com.edgareldy.springjdbctutorial.core.customer.mapper;
+
+import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto;
+import com.edgareldy.springjdbctutorial.core.customer.entity.Customer;
+
+/**
+ * Converts between the Customer entity and the CustomerDto.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class CustomerMapper {
+
+ public CustomerDto toDto(Customer customer) {
+ if (customer == null) {
+ return null;
+ }
+ return new CustomerDto(customer.getId(), customer.getFirstName(), customer.getLastName(),
+ customer.getTelephone(), customer.getEmail(), customer.getAddress());
+ }
+
+ public Customer toEntity(CustomerDto dto) {
+ if (dto == null) {
+ return null;
+ }
+ return new Customer(dto.getId(), dto.getFirstName(), dto.getLastName(),
+ dto.getTelephone(), dto.getEmail(), dto.getAddress());
+ }
+}
diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapper.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapper.java
new file mode 100644
index 0000000..1a18dc9
--- /dev/null
+++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapper.java
@@ -0,0 +1,29 @@
+package com.edgareldy.springjdbctutorial.core.customer.mapper;
+
+import com.edgareldy.springjdbctutorial.core.customer.entity.Customer;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+/**
+ * Converts one row of the customers table into a Customer entity. Telephone, email and address are nullable columns.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class CustomerRowMapper implements RowMapper {
+
+ @Override
+ public Customer mapRow(ResultSet rs, int rowNum) throws SQLException {
+ return new Customer(
+ rs.getLong("id"),
+ rs.getString("first_name"),
+ rs.getString("last_name"),
+ rs.getString("telephone"),
+ rs.getString("email"),
+ rs.getString("address"));
+ }
+}
diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/CustomerService.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/CustomerService.java
new file mode 100644
index 0000000..1b531ee
--- /dev/null
+++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/CustomerService.java
@@ -0,0 +1,29 @@
+package com.edgareldy.springjdbctutorial.core.customer.service;
+
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto;
+
+/**
+ * Business operations on customers. Dto types only at this boundary.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public interface CustomerService {
+
+ /** One page of customers. Page must be >= 0 and size in 1..100, else BusinessRuleException. */
+ PageDto list(int page, int size);
+
+ /** Throws ResourceNotFoundException when the id is unknown. */
+ CustomerDto get(Long id);
+
+ /** Names are required, telephone, email and address are optional (blank becomes null). */
+ CustomerDto create(CustomerDto input);
+
+ CustomerDto update(Long id, CustomerDto input);
+
+ /** Refused with BusinessRuleException while the customer is referenced by orders. */
+ void delete(Long id);
+}
diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImpl.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImpl.java
new file mode 100644
index 0000000..2891600
--- /dev/null
+++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImpl.java
@@ -0,0 +1,148 @@
+package com.edgareldy.springjdbctutorial.core.customer.service.impl;
+
+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.dao.CustomerDao;
+import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto;
+import com.edgareldy.springjdbctutorial.core.customer.entity.Customer;
+import com.edgareldy.springjdbctutorial.core.customer.mapper.CustomerMapper;
+import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implementation of CustomerService. 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 CustomerServiceImpl implements CustomerService {
+
+ private static final int MAX_PAGE_SIZE = 100;
+ private static final int MAX_NAME_LENGTH = 100;
+ private static final int MAX_TELEPHONE_LENGTH = 30;
+ private static final int MAX_EMAIL_LENGTH = 255;
+ private static final int MAX_ADDRESS_LENGTH = 255;
+
+ private final CustomerDao customerDao;
+ private final CustomerMapper mapper = new CustomerMapper();
+
+ public CustomerServiceImpl(CustomerDao customerDao) {
+ this.customerDao = customerDao;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public PageDto list(int page, int size) {
+ if (page < 0) {
+ throw new BusinessRuleException("Page must be greater than or equal to 0");
+ }
+ if (size < 1 || size > MAX_PAGE_SIZE) {
+ throw new BusinessRuleException("Size must be between 1 and " + MAX_PAGE_SIZE);
+ }
+ List content = new ArrayList<>();
+ for (Customer customer : customerDao.findPage(page, size)) {
+ content.add(mapper.toDto(customer));
+ }
+ return new PageDto<>(content, page, size, customerDao.countAll());
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public CustomerDto get(Long id) {
+ return mapper.toDto(load(id));
+ }
+
+ @Override
+ @Transactional
+ public CustomerDto create(CustomerDto input) {
+ return mapper.toDto(customerDao.insert(clean(null, input)));
+ }
+
+ @Override
+ @Transactional
+ public CustomerDto update(Long id, CustomerDto input) {
+ load(id);
+ Customer customer = clean(id, input);
+ if (customerDao.update(customer) == 0) {
+ // The customer was deleted between the load and the update
+ throw new ResourceNotFoundException("Customer not found: " + id);
+ }
+ return mapper.toDto(customer);
+ }
+
+ @Override
+ @Transactional
+ public void delete(Long id) {
+ load(id);
+ try {
+ customerDao.delete(id);
+ } catch (DataIntegrityViolationException e) {
+ // The foreign key from orders refused the delete
+ throw new BusinessRuleException("Customer is referenced by orders", e);
+ }
+ }
+
+ private Customer load(Long id) {
+ return customerDao.findById(id)
+ .orElseThrow(() -> new ResourceNotFoundException("Customer not found: " + id));
+ }
+
+ private static Customer clean(Long id, CustomerDto input) {
+ String firstName = required(input.getFirstName(), "First name", MAX_NAME_LENGTH);
+ String lastName = required(input.getLastName(), "Last name", MAX_NAME_LENGTH);
+ String telephone = optional(input.getTelephone(), "Telephone", MAX_TELEPHONE_LENGTH);
+ String email = optional(input.getEmail(), "Email", MAX_EMAIL_LENGTH);
+ if (email != null && !isValidEmail(email)) {
+ throw new BusinessRuleException("Email is not valid");
+ }
+ String address = optional(input.getAddress(), "Address", MAX_ADDRESS_LENGTH);
+ return new Customer(id, firstName, lastName, telephone, email, address);
+ }
+
+ private static String required(String raw, String label, int max) {
+ String value = raw == null ? "" : raw.trim();
+ if (value.isEmpty()) {
+ throw new BusinessRuleException(label + " must not be blank");
+ }
+ return checkLength(value, label, max);
+ }
+
+ /** Trims, turns blank into null, then checks the length. */
+ private static String optional(String raw, String label, int max) {
+ String value = raw == null ? "" : raw.trim();
+ if (value.isEmpty()) {
+ return null;
+ }
+ return checkLength(value, label, max);
+ }
+
+ private static String checkLength(String value, String label, int max) {
+ if (value.length() > max) {
+ throw new BusinessRuleException(label + " must be at most " + max + " characters");
+ }
+ return value;
+ }
+
+ /** Basic format: no whitespace, one @, a non-empty local part and a domain containing a dot. */
+ private static boolean isValidEmail(String email) {
+ for (int i = 0; i < email.length(); i++) {
+ if (Character.isWhitespace(email.charAt(i))) {
+ return false;
+ }
+ }
+ int at = email.indexOf('@');
+ if (at < 1 || at != email.lastIndexOf('@')) {
+ return false;
+ }
+ String domain = email.substring(at + 1);
+ int dot = domain.indexOf('.');
+ return dot > 0 && !domain.endsWith(".");
+ }
+}
diff --git a/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImplTest.java b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImplTest.java
new file mode 100644
index 0000000..a1db80e
--- /dev/null
+++ b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImplTest.java
@@ -0,0 +1,152 @@
+package com.edgareldy.springjdbctutorial.core.customer.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.customer.config.DaoConfig;
+import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao;
+import com.edgareldy.springjdbctutorial.core.customer.entity.Customer;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.jdbc.core.JdbcTemplate;
+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 CustomerDaoImpl against the real PostgreSQL, with its own fixture file customer-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("/customer-dao-dataset.sql")
+class CustomerDaoImplTest {
+
+ @DynamicPropertySource
+ static void datasource(DynamicPropertyRegistry registry) {
+ PostgresTestContainer.register(registry);
+ }
+
+ @Autowired
+ private CustomerDao customerDao;
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @Test
+ void _01_ShouldReturnEveryColumn_WhenIdExists() {
+ Customer customer = customerDao.findById(1L).orElseThrow();
+
+ assertThat(customer).isEqualTo(new Customer(1L, "Alice", "Martin", "+33 1 23 45", "alice@example.com", "1 Main Street"));
+ }
+
+ @Test
+ void _02_ShouldReturnEmpty_WhenIdDoesNotExist() {
+ assertThat(customerDao.findById(999L)).isEmpty();
+ }
+
+ @Test
+ void _03_ShouldReadNullOptionalColumns_WhenTheyAreNullInTheRow() {
+ Customer customer = customerDao.findById(2L).orElseThrow();
+
+ assertThat(customer.getFirstName()).isEqualTo("Bob");
+ assertThat(customer.getTelephone()).isNull();
+ assertThat(customer.getEmail()).isNull();
+ assertThat(customer.getAddress()).isNull();
+ }
+
+ @Test
+ void _04_ShouldReturnFirstPageOrderedById_WhenPageIsZero() {
+ assertThat(customerDao.findPage(0, 3)).extracting(Customer::getId).containsExactly(1L, 2L, 3L);
+ }
+
+ @Test
+ void _05_ShouldApplyOffsetOfPageTimesSize_WhenPageIsOne() {
+ assertThat(customerDao.findPage(1, 3)).extracting(Customer::getId).containsExactly(4L);
+ }
+
+ @Test
+ void _06_ShouldReturnEmptyList_WhenPageIsBeyondTheLastOne() {
+ assertThat(customerDao.findPage(50, 10)).isEmpty();
+ }
+
+ @Test
+ void _07_ShouldCountEveryRow_WhenCountingAll() {
+ assertThat(customerDao.countAll()).isEqualTo(4L);
+ }
+
+ @Test
+ void _08_ShouldGenerateIdAndPersistEveryColumn_WhenCustomerIsInserted() {
+ Customer created = customerDao.insert(new Customer(null, "Eve", "Adams", "555", "eve@example.com", "5 Road"));
+
+ assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L);
+ assertThat(customerDao.findById(created.getId())).contains(created);
+ assertThat(customerDao.countAll()).isEqualTo(5L);
+ }
+
+ @Test
+ void _09_ShouldRoundTripNulls_WhenOptionalColumnsAreNullOnInsert() {
+ Customer created = customerDao.insert(new Customer(null, "Zed", "Zulu", null, null, null));
+
+ Customer read = customerDao.findById(created.getId()).orElseThrow();
+
+ assertThat(read.getTelephone()).isNull();
+ assertThat(read.getEmail()).isNull();
+ assertThat(read.getAddress()).isNull();
+ }
+
+ @Test
+ void _10_ShouldReturnOneAndChangeEveryColumn_WhenCustomerExists() {
+ int affected = customerDao.update(new Customer(2L, "Robert", "Stone", "111", "rob@example.com", "2 Road"));
+
+ assertThat(affected).isEqualTo(1);
+ assertThat(customerDao.findById(2L)).contains(new Customer(2L, "Robert", "Stone", "111", "rob@example.com", "2 Road"));
+ }
+
+ @Test
+ void _11_ShouldWriteNulls_WhenUpdatedOptionalColumnsAreNull() {
+ customerDao.update(new Customer(1L, "Alice", "Martin", null, null, null));
+
+ Customer read = customerDao.findById(1L).orElseThrow();
+
+ assertThat(read.getTelephone()).isNull();
+ assertThat(read.getEmail()).isNull();
+ assertThat(read.getAddress()).isNull();
+ }
+
+ @Test
+ void _12_ShouldReturnZeroAndChangeNothing_WhenUpdatingAnUnknownId() {
+ int affected = customerDao.update(new Customer(999L, "Ghost", "Nobody", null, null, null));
+
+ assertThat(affected).isZero();
+ assertThat(customerDao.countAll()).isEqualTo(4L);
+ }
+
+ @Test
+ void _13_ShouldDeleteCustomer_WhenNoOrderReferencesIt() {
+ customerDao.delete(4L);
+
+ assertThat(customerDao.findById(4L)).isEmpty();
+ assertThat(customerDao.countAll()).isEqualTo(3L);
+ }
+
+ @Test
+ void _14_ShouldThrowDataIntegrityViolationException_WhenAnOrderStillReferencesTheCustomer() {
+ // The order endpoints do not exist yet: the referencing row is inserted straight through SQL
+ jdbcTemplate.update("INSERT INTO orders (customer_id, product_id, quantity, total) VALUES (3, 1, 2, 25.00)");
+
+ assertThatThrownBy(() -> customerDao.delete(3L)).isInstanceOf(DataIntegrityViolationException.class);
+
+ assertThat(customerDao.findById(3L)).isPresent();
+ }
+}
diff --git a/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapperTest.java b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapperTest.java
new file mode 100644
index 0000000..d9ae8fa
--- /dev/null
+++ b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapperTest.java
@@ -0,0 +1,53 @@
+package com.edgareldy.springjdbctutorial.core.customer.mapper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto;
+import com.edgareldy.springjdbctutorial.core.customer.entity.Customer;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests CustomerMapper (entity to dto and back) on hand-built objects, no mocks.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+class CustomerMapperTest {
+
+ private final CustomerMapper mapper = new CustomerMapper();
+
+ @Test
+ void _01_ShouldCopyEveryField_WhenConvertingEntityToDto() {
+ CustomerDto dto = mapper.toDto(new Customer(3L, "Alice", "Martin", "555", "a@b.co", "Street"));
+
+ assertThat(dto).isEqualTo(new CustomerDto(3L, "Alice", "Martin", "555", "a@b.co", "Street"));
+ }
+
+ @Test
+ void _02_ShouldKeepNulls_WhenOptionalFieldsAreNull() {
+ CustomerDto dto = mapper.toDto(new Customer(3L, "Bob", "Stone", null, null, null));
+
+ assertThat(dto.getTelephone()).isNull();
+ assertThat(dto.getEmail()).isNull();
+ assertThat(dto.getAddress()).isNull();
+ }
+
+ @Test
+ void _03_ShouldReturnNull_WhenEntityIsNull() {
+ assertThat(mapper.toDto(null)).isNull();
+ }
+
+ @Test
+ void _04_ShouldCopyEveryField_WhenConvertingDtoToEntity() {
+ Customer customer = mapper.toEntity(new CustomerDto(3L, "Alice", "Martin", "555", "a@b.co", "Street"));
+
+ assertThat(customer).isEqualTo(new Customer(3L, "Alice", "Martin", "555", "a@b.co", "Street"));
+ }
+
+ @Test
+ void _05_ShouldReturnNull_WhenDtoIsNull() {
+ assertThat(mapper.toEntity(null)).isNull();
+ }
+}
diff --git a/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapperTest.java b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapperTest.java
new file mode 100644
index 0000000..2a88bff
--- /dev/null
+++ b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapperTest.java
@@ -0,0 +1,58 @@
+package com.edgareldy.springjdbctutorial.core.customer.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.customer.entity.Customer;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests CustomerRowMapper 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 CustomerRowMapperTest {
+
+ private final CustomerRowMapper mapper = new CustomerRowMapper();
+
+ // Mockito.mock(ResultSet.class) is a fake row whose getters return what the test stubs, column by
+ // column: the test only checks the column-to-field mapping, with no database and no driver.
+ @Test
+ void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException {
+ ResultSet rs = mock(ResultSet.class);
+ when(rs.getLong("id")).thenReturn(7L);
+ when(rs.getString("first_name")).thenReturn("Alice");
+ when(rs.getString("last_name")).thenReturn("Martin");
+ when(rs.getString("telephone")).thenReturn("555");
+ when(rs.getString("email")).thenReturn("alice@example.com");
+ when(rs.getString("address")).thenReturn("1 Main Street");
+
+ Customer customer = mapper.mapRow(rs, 0);
+
+ assertThat(customer).isEqualTo(new Customer(7L, "Alice", "Martin", "555", "alice@example.com", "1 Main Street"));
+ }
+
+ @Test
+ void _02_ShouldKeepNulls_WhenTelephoneEmailAndAddressAreNull() throws SQLException {
+ ResultSet rs = mock(ResultSet.class);
+ when(rs.getLong("id")).thenReturn(8L);
+ when(rs.getString("first_name")).thenReturn("Bob");
+ when(rs.getString("last_name")).thenReturn("Stone");
+ when(rs.getString("telephone")).thenReturn(null);
+ when(rs.getString("email")).thenReturn(null);
+ when(rs.getString("address")).thenReturn(null);
+
+ Customer customer = mapper.mapRow(rs, 0);
+
+ assertThat(customer.getId()).isEqualTo(8L);
+ assertThat(customer.getTelephone()).isNull();
+ assertThat(customer.getEmail()).isNull();
+ assertThat(customer.getAddress()).isNull();
+ }
+}
diff --git a/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImplTest.java b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImplTest.java
new file mode 100644
index 0000000..3044ac0
--- /dev/null
+++ b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImplTest.java
@@ -0,0 +1,300 @@
+package com.edgareldy.springjdbctutorial.core.customer.service.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.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.dao.CustomerDao;
+import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto;
+import com.edgareldy.springjdbctutorial.core.customer.entity.Customer;
+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 CustomerServiceImpl: the DAO is a Mockito mock, 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 CustomerServiceImplTest {
+
+ @Mock
+ private CustomerDao customerDao;
+
+ private CustomerServiceImpl service() {
+ return new CustomerServiceImpl(customerDao);
+ }
+
+ private static CustomerDto input(String first, String last, String telephone, String email, String address) {
+ return new CustomerDto(null, first, last, telephone, email, address);
+ }
+
+ private static CustomerDto withEmail(String email) {
+ return input("Alice", "Martin", null, email, null);
+ }
+
+ private Customer created() {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Customer.class);
+ verify(customerDao).insert(captor.capture());
+ return captor.getValue();
+ }
+
+ // ---------------------------------------------------------------- list
+
+ @Test
+ void _01_ShouldReturnPageOfDtos_WhenListing() {
+ when(customerDao.findPage(1, 2)).thenReturn(List.of(new Customer(3L, "Carol", "Diaz", null, null, null)));
+ when(customerDao.countAll()).thenReturn(3L);
+
+ PageDto page = service().list(1, 2);
+
+ assertThat(page.getContent()).containsExactly(new CustomerDto(3L, "Carol", "Diaz", null, null, null));
+ assertThat(page.getPage()).isEqualTo(1);
+ assertThat(page.getSize()).isEqualTo(2);
+ assertThat(page.getTotalElements()).isEqualTo(3L);
+ }
+
+ @Test
+ void _02_ShouldReturnEmptyContent_WhenPageIsBeyondTheLastOne() {
+ when(customerDao.findPage(50, 10)).thenReturn(List.of());
+ when(customerDao.countAll()).thenReturn(3L);
+
+ PageDto page = service().list(50, 10);
+
+ assertThat(page.getContent()).isEmpty();
+ assertThat(page.getTotalElements()).isEqualTo(3L);
+ }
+
+ @Test
+ void _03_ShouldThrowBusinessRuleException_WhenPageIsNegative() {
+ assertThatThrownBy(() -> service().list(-1, 10)).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(customerDao);
+ }
+
+ @Test
+ void _04_ShouldThrowBusinessRuleException_WhenSizeIsZero() {
+ assertThatThrownBy(() -> service().list(0, 0)).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(customerDao);
+ }
+
+ @Test
+ void _05_ShouldThrowBusinessRuleException_WhenSizeIsAbove100() {
+ assertThatThrownBy(() -> service().list(0, 101)).isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(customerDao);
+ }
+
+ @Test
+ void _06_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() {
+ when(customerDao.findPage(0, 1)).thenReturn(List.of());
+ when(customerDao.findPage(0, 100)).thenReturn(List.of());
+ when(customerDao.countAll()).thenReturn(0L);
+
+ assertThat(service().list(0, 1).getSize()).isEqualTo(1);
+ assertThat(service().list(0, 100).getSize()).isEqualTo(100);
+ }
+
+ // ---------------------------------------------------------------- get
+
+ @Test
+ void _07_ShouldReturnDto_WhenCustomerExists() {
+ when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", "1", "b@s.io", "Rd")));
+
+ assertThat(service().get(2L)).isEqualTo(new CustomerDto(2L, "Bob", "Stone", "1", "b@s.io", "Rd"));
+ }
+
+ @Test
+ void _08_ShouldThrowResourceNotFoundException_WhenCustomerDoesNotExist() {
+ when(customerDao.findById(99L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service().get(99L))
+ .isInstanceOf(ResourceNotFoundException.class).hasMessage("Customer not found: 99");
+ }
+
+ // ---------------------------------------------------------------- create
+
+ @Test
+ void _09_ShouldTrimEveryFieldAndReturnGeneratedId_WhenCreatingACustomer() {
+ when(customerDao.insert(any(Customer.class))).thenAnswer(call -> {
+ Customer customer = call.getArgument(0);
+ customer.setId(7L);
+ return customer;
+ });
+
+ CustomerDto dto = service().create(input(" Alice ", " Martin ", " 555 ", " alice@example.com ", " 1 Main "));
+
+ assertThat(dto).isEqualTo(new CustomerDto(7L, "Alice", "Martin", "555", "alice@example.com", "1 Main"));
+ assertThat(created().getId()).isEqualTo(7L);
+ }
+
+ @Test
+ void _10_ShouldStoreNull_WhenOptionalFieldsAreBlankOrAbsent() {
+ when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0));
+
+ service().create(input("Alice", "Martin", " ", "", null));
+
+ Customer stored = created();
+ assertThat(stored.getTelephone()).isNull();
+ assertThat(stored.getEmail()).isNull();
+ assertThat(stored.getAddress()).isNull();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"", " "})
+ void _11_ShouldThrowBusinessRuleException_WhenFirstOrLastNameIsBlank(String blank) {
+ assertThatThrownBy(() -> service().create(input(blank, "Martin", null, null, null)))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("First name must not be blank");
+ assertThatThrownBy(() -> service().create(input("Alice", blank, null, null, null)))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("Last name must not be blank");
+ assertThatThrownBy(() -> service().create(input(null, "Martin", null, null, null)))
+ .isInstanceOf(BusinessRuleException.class);
+ verifyNoInteractions(customerDao);
+ }
+
+ @Test
+ void _12_ShouldAcceptNamesOf100AndRefuse101_WhenCheckingTheNameLimit() {
+ when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0));
+
+ service().create(input("x".repeat(100), "y".repeat(100), null, null, null));
+
+ assertThatThrownBy(() -> service().create(input("x".repeat(101), "Martin", null, null, null)))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("First name must be at most 100 characters");
+ assertThatThrownBy(() -> service().create(input("Alice", "y".repeat(101), null, null, null)))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("Last name must be at most 100 characters");
+ }
+
+ @Test
+ void _13_ShouldAcceptTelephoneOf30AndRefuse31_WhenCheckingTheTelephoneLimit() {
+ when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0));
+
+ service().create(input("Alice", "Martin", "1".repeat(30), null, null));
+
+ assertThatThrownBy(() -> service().create(input("Alice", "Martin", "1".repeat(31), null, null)))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("Telephone must be at most 30 characters");
+ }
+
+ @Test
+ void _14_ShouldAcceptAddressOf255AndRefuse256_WhenCheckingTheAddressLimit() {
+ when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0));
+
+ service().create(input("Alice", "Martin", null, null, "a".repeat(255)));
+
+ assertThatThrownBy(() -> service().create(input("Alice", "Martin", null, null, "a".repeat(256))))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("Address must be at most 255 characters");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"alice@example.com", "a.b+c@sub.example.org", "a@b.c"})
+ void _15_ShouldAcceptEmail_WhenFormatIsValid(String email) {
+ when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0));
+
+ assertThat(service().create(withEmail(email)).getEmail()).isEqualTo(email);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"alice.example.com", "a@@example.com", "a@b@example.com", "alice@example", "alice@.com",
+ "alice@example.", "ali ce@example.com", "alice@exa mple.com", "@example.com"})
+ void _16_ShouldThrowBusinessRuleException_WhenEmailFormatIsInvalid(String email) {
+ assertThatThrownBy(() -> service().create(withEmail(email)))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("Email is not valid");
+ verify(customerDao, never()).insert(any());
+ }
+
+ @Test
+ void _17_ShouldThrowBusinessRuleException_WhenEmailIsLongerThan255() {
+ String email = "a".repeat(250) + "@b.com";
+
+ assertThatThrownBy(() -> service().create(withEmail(email)))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("Email must be at most 255 characters");
+ }
+
+ // ---------------------------------------------------------------- update
+
+ @Test
+ void _18_ShouldUpdateAndReturnCleanedDto_WhenCustomerExists() {
+ when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null)));
+ when(customerDao.update(any(Customer.class))).thenReturn(1);
+
+ CustomerDto dto = service().update(2L, input(" Robert ", "Stone", " ", "rob@example.com", null));
+
+ assertThat(dto).isEqualTo(new CustomerDto(2L, "Robert", "Stone", null, "rob@example.com", null));
+ ArgumentCaptor sent = ArgumentCaptor.forClass(Customer.class);
+ verify(customerDao).update(sent.capture());
+ assertThat(sent.getValue().getId()).isEqualTo(2L);
+ }
+
+ @Test
+ void _19_ShouldThrowResourceNotFoundExceptionWithoutUpdating_WhenCustomerDoesNotExist() {
+ when(customerDao.findById(99L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service().update(99L, input("Alice", "Martin", null, null, null)))
+ .isInstanceOf(ResourceNotFoundException.class).hasMessage("Customer not found: 99");
+ verify(customerDao, never()).update(any());
+ }
+
+ @Test
+ void _20_ShouldThrowResourceNotFoundException_WhenNoRowIsAffectedByTheUpdate() {
+ when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null)));
+ when(customerDao.update(any(Customer.class))).thenReturn(0);
+
+ assertThatThrownBy(() -> service().update(2L, input("Bob", "Stone", null, null, null)))
+ .isInstanceOf(ResourceNotFoundException.class).hasMessage("Customer not found: 2");
+ }
+
+ @Test
+ void _21_ShouldThrowBusinessRuleExceptionWithoutUpdating_WhenUpdatedEmailIsInvalid() {
+ when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null)));
+
+ assertThatThrownBy(() -> service().update(2L, withEmail("nope")))
+ .isInstanceOf(BusinessRuleException.class);
+ verify(customerDao, never()).update(any());
+ }
+
+ // ---------------------------------------------------------------- delete
+
+ @Test
+ void _22_ShouldDelete_WhenCustomerExistsAndIsNotReferenced() {
+ when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null)));
+
+ service().delete(2L);
+
+ verify(customerDao).delete(2L);
+ }
+
+ @Test
+ void _23_ShouldThrowResourceNotFoundExceptionWithoutDeleting_WhenCustomerDoesNotExist() {
+ when(customerDao.findById(99L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service().delete(99L)).isInstanceOf(ResourceNotFoundException.class);
+ verify(customerDao, never()).delete(any());
+ }
+
+ @Test
+ void _24_ShouldThrowBusinessRuleException_WhenTheForeignKeyOfOrdersRefusesTheDelete() {
+ when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null)));
+ doThrow(new DataIntegrityViolationException("fk_orders_customer")).when(customerDao).delete(2L);
+
+ assertThatThrownBy(() -> service().delete(2L))
+ .isInstanceOf(BusinessRuleException.class).hasMessage("Customer is referenced by orders");
+ }
+}
diff --git a/core/customer/src/test/resources/customer-dao-dataset.sql b/core/customer/src/test/resources/customer-dao-dataset.sql
new file mode 100644
index 0000000..9d4d59a
--- /dev/null
+++ b/core/customer/src/test/resources/customer-dao-dataset.sql
@@ -0,0 +1,19 @@
+-- Fixture of CustomerDaoImplTest only. Wipes the customer and order tables (and the catalog rows an order needs),
+-- then loads four customers: Alice (1) and Carol (3) and Dan (4) with every column, Bob (2) with NULL telephone,
+-- email and address. One category and one product exist so a test can insert an order by SQL.
+TRUNCATE TABLE orders, products, categories, customers RESTART IDENTITY CASCADE;
+
+INSERT INTO customers (id, first_name, last_name, telephone, email, address) VALUES
+ (1, 'Alice', 'Martin', '+33 1 23 45', 'alice@example.com', '1 Main Street'),
+ (2, 'Bob', 'Stone', NULL, NULL, NULL),
+ (3, 'Carol', 'Diaz', '555-0303', 'carol@example.com', '3 Side Road'),
+ (4, 'Dan', 'Lee', '555-0404', 'dan@example.com', '4 Long Avenue');
+
+INSERT INTO categories (id, category_name) VALUES (1, 'Books');
+INSERT INTO products (id, category_id, product_name, unit_price) VALUES (1, 1, 'Novel', 12.50);
+
+-- 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 e1185f7..0657ea5 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
@@ -38,7 +38,8 @@
@Configuration
@EnableWebMvc
@Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class,
- com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class, SecurityConfig.class})
+ com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class,
+ com.edgareldy.springjdbctutorial.core.customer.config.ServiceConfig.class, SecurityConfig.class})
@ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"})
public class WebMvcConfig implements WebMvcConfigurer {
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CustomerController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CustomerController.java
new file mode 100644
index 0000000..3487790
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CustomerController.java
@@ -0,0 +1,77 @@
+package com.edgareldy.springjdbctutorial.ws.controller;
+
+import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService;
+import com.edgareldy.springjdbctutorial.ws.converter.CustomerConverter;
+import com.edgareldy.springjdbctutorial.ws.payload.customer.CustomerRequest;
+import com.edgareldy.springjdbctutorial.ws.payload.customer.CustomerResponse;
+import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse;
+import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * HTTP entry points to manage customers. 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/customers")
+public class CustomerController {
+
+ private final CustomerService customerService;
+
+ public CustomerController(CustomerService customerService) {
+ this.customerService = customerService;
+ }
+
+ @GetMapping
+ @PreAuthorize("hasPermission('CUSTOMER','READ')")
+ public ApiResponse> list(@RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return ApiResponse.success(CustomerConverter.toPageResponse(customerService.list(page, size)), "Customers");
+ }
+
+ @GetMapping("/{id}")
+ @PreAuthorize("hasPermission('CUSTOMER','READ')")
+ public ApiResponse get(@PathVariable Long id) {
+ return ApiResponse.success(CustomerConverter.toResponse(customerService.get(id)), "Customer");
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ @PreAuthorize("hasPermission('CUSTOMER','WRITE')")
+ public ApiResponse create(@Valid @RequestBody CustomerRequest request) {
+ return ApiResponse.success(CustomerConverter.toResponse(customerService.create(CustomerConverter.toDto(request))),
+ "Customer created");
+ }
+
+ @PutMapping("/{id}")
+ @PreAuthorize("hasPermission('CUSTOMER','WRITE')")
+ public ApiResponse update(@PathVariable Long id, @Valid @RequestBody CustomerRequest request) {
+ return ApiResponse.success(CustomerConverter.toResponse(customerService.update(id, CustomerConverter.toDto(request))),
+ "Customer updated");
+ }
+
+ @DeleteMapping("/{id}")
+ @PreAuthorize("hasPermission('CUSTOMER','WRITE')")
+ public ApiResponse delete(@PathVariable Long id) {
+ customerService.delete(id);
+ return ApiResponse.success(null, "Customer deleted");
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CustomerConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CustomerConverter.java
new file mode 100644
index 0000000..fcd7fca
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CustomerConverter.java
@@ -0,0 +1,47 @@
+package com.edgareldy.springjdbctutorial.ws.converter;
+
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto;
+import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse;
+import com.edgareldy.springjdbctutorial.ws.payload.customer.CustomerRequest;
+import com.edgareldy.springjdbctutorial.ws.payload.customer.CustomerResponse;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Converts between the core CustomerDto and the customer HTTP payloads.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public final class CustomerConverter {
+
+ private CustomerConverter() {
+ }
+
+ public static CustomerDto toDto(CustomerRequest request) {
+ CustomerDto dto = new CustomerDto();
+ dto.setFirstName(request.getFirstName());
+ dto.setLastName(request.getLastName());
+ dto.setTelephone(request.getTelephone());
+ dto.setEmail(request.getEmail());
+ dto.setAddress(request.getAddress());
+ return dto;
+ }
+
+ public static CustomerResponse toResponse(CustomerDto dto) {
+ return new CustomerResponse(dto.getId(), dto.getFirstName(), dto.getLastName(),
+ dto.getTelephone(), dto.getEmail(), dto.getAddress());
+ }
+
+ public static PageResponse toPageResponse(PageDto page) {
+ List content = new ArrayList<>();
+ for (CustomerDto 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/customer/CustomerRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerRequest.java
new file mode 100644
index 0000000..2c648ad
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerRequest.java
@@ -0,0 +1,110 @@
+package com.edgareldy.springjdbctutorial.ws.payload.customer;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+import java.util.Objects;
+
+/**
+ * Body of the create and update customer calls. Optional fields pass when null or empty, the service turns blank ones into null.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+public class CustomerRequest {
+
+ @NotBlank
+ @Size(max = 100)
+ private String firstName;
+
+ @NotBlank
+ @Size(max = 100)
+ private String lastName;
+
+ @Size(max = 30)
+ private String telephone;
+
+ // @Email accepts null and the empty string, so an absent or empty email is not a validation error
+ @Email
+ @Size(max = 255)
+ private String email;
+
+ @Size(max = 255)
+ private String address;
+
+ public CustomerRequest() {
+ }
+
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public void setFirstName(String firstName) {
+ this.firstName = firstName;
+ }
+
+ public String getLastName() {
+ return lastName;
+ }
+
+ public void setLastName(String lastName) {
+ this.lastName = lastName;
+ }
+
+ public String getTelephone() {
+ return telephone;
+ }
+
+ public void setTelephone(String telephone) {
+ this.telephone = telephone;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ public String getAddress() {
+ return address;
+ }
+
+ public void setAddress(String address) {
+ this.address = address;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ CustomerRequest that = (CustomerRequest) o;
+ return Objects.equals(firstName, that.firstName) && Objects.equals(lastName, that.lastName)
+ && Objects.equals(telephone, that.telephone) && Objects.equals(email, that.email)
+ && Objects.equals(address, that.address);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(firstName, lastName, telephone, email, address);
+ }
+
+ @Override
+ public String toString() {
+ return "CustomerRequest{" +
+ "firstName=" + firstName +
+ ", lastName=" + lastName +
+ ", telephone=" + telephone +
+ ", email=" + email +
+ ", address=" + address +
+ "}";
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerResponse.java
new file mode 100644
index 0000000..52192bb
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerResponse.java
@@ -0,0 +1,117 @@
+package com.edgareldy.springjdbctutorial.ws.payload.customer;
+
+import java.util.Objects;
+
+/**
+ * A customer 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 CustomerResponse {
+
+ private Long id;
+
+ private String firstName;
+
+ private String lastName;
+
+ private String telephone;
+
+ private String email;
+
+ private String address;
+
+ public CustomerResponse() {
+ }
+
+ public CustomerResponse(Long id, String firstName, String lastName, String telephone, String email, String address) {
+ this.id = id;
+ this.firstName = firstName;
+ this.lastName = lastName;
+ this.telephone = telephone;
+ this.email = email;
+ this.address = address;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public void setFirstName(String firstName) {
+ this.firstName = firstName;
+ }
+
+ public String getLastName() {
+ return lastName;
+ }
+
+ public void setLastName(String lastName) {
+ this.lastName = lastName;
+ }
+
+ public String getTelephone() {
+ return telephone;
+ }
+
+ public void setTelephone(String telephone) {
+ this.telephone = telephone;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ public String getAddress() {
+ return address;
+ }
+
+ public void setAddress(String address) {
+ this.address = address;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ CustomerResponse that = (CustomerResponse) o;
+ return Objects.equals(id, that.id) && Objects.equals(firstName, that.firstName)
+ && Objects.equals(lastName, that.lastName) && Objects.equals(telephone, that.telephone)
+ && Objects.equals(email, that.email) && Objects.equals(address, that.address);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, firstName, lastName, telephone, email, address);
+ }
+
+ @Override
+ public String toString() {
+ return "CustomerResponse{" +
+ "id=" + id +
+ ", firstName=" + firstName +
+ ", lastName=" + lastName +
+ ", telephone=" + telephone +
+ ", email=" + email +
+ ", address=" + address +
+ "}";
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CustomerFlowIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CustomerFlowIntegrationTest.java
new file mode 100644
index 0000000..c25ddee
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CustomerFlowIntegrationTest.java
@@ -0,0 +1,303 @@
+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.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 customer flows through the REAL stack (real WebMvcConfig, real service, real PostgreSQL migrated by
+ * Flyway, real logins): CRUD, optional fields, the email format rules at both levels, the delete refused while
+ * orders reference the customer, pagination bounds and permission enforcement.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+// Same recipe as CatalogFlowIntegrationTest: the whole application context on the shared Testcontainers database.
+// Every customer a test creates has a it- first name, and tearDown() removes them (with the orders, product
+// and category some tests insert by SQL, and the users and roles of RbacTestSupport).
+@SpringJUnitWebConfig(WebMvcConfig.class)
+class CustomerFlowIntegrationTest {
+
+ private static final String PREFIX = "it-";
+ private static final String URL = "/api/v1/customers";
+
+ @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() {
+ // Orders first: they reference the customers and the products
+ jdbcTemplate.update("DELETE FROM orders WHERE customer_id IN (SELECT id FROM customers WHERE first_name LIKE ?)",
+ 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 String body(String firstName, String lastName, String telephone, String email, String address) {
+ return "{\"firstName\":\"" + firstName + "\",\"lastName\":\"" + lastName + "\",\"telephone\":" + quote(telephone)
+ + ",\"email\":" + quote(email) + ",\"address\":" + quote(address) + "}";
+ }
+
+ private static String quote(String value) {
+ return value == null ? "null" : "\"" + value + "\"";
+ }
+
+ private static String minimalBody(String firstName) {
+ return "{\"firstName\":\"" + firstName + "\",\"lastName\":\"Martin\"}";
+ }
+
+ private static long idOf(MvcResult result) throws Exception {
+ return assertSuccess(result, 201).get("data").get("id").asLong();
+ }
+
+ private long count(String sql, Object... args) {
+ return jdbcTemplate.queryForObject(sql, Long.class, args);
+ }
+
+ @Test
+ void _01_ShouldCreateReadUpdateListAndDeleteACustomer_WhenAdminRunsTheFullCrud() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ String name = unique();
+
+ MvcResult created = support.post(jwt, URL, body(" " + name + " ", " Martin ", " 555 ", "a@example.com", " 1 Main "));
+ long id = idOf(created);
+
+ // Every text is stored trimmed (the email is sent without spaces: @Email rejects surrounding spaces with a 400)
+ JsonNode read = assertSuccess(support.get(jwt, URL + "/" + id), 200).get("data");
+ assertThat(read.get("firstName").asText()).isEqualTo(name);
+ assertThat(read.get("lastName").asText()).isEqualTo("Martin");
+ assertThat(read.get("telephone").asText()).isEqualTo("555");
+ assertThat(read.get("email").asText()).isEqualTo("a@example.com");
+ assertThat(read.get("address").asText()).isEqualTo("1 Main");
+
+ String renamed = unique();
+ JsonNode updated = assertSuccess(support.put(jwt, URL + "/" + id,
+ body(renamed, "Stone", "777", "b@example.com", "2 Road")), 200);
+ assertThat(updated.get("data").get("firstName").asText()).isEqualTo(renamed);
+ assertThat(count("SELECT COUNT(*) FROM customers WHERE first_name = ? AND last_name = 'Stone' AND telephone = '777'",
+ renamed)).isEqualTo(1L);
+
+ JsonNode list = assertSuccess(support.get(jwt, URL + "?size=100"), 200);
+ boolean listed = false;
+ for (JsonNode customer : list.get("data").get("content")) {
+ listed |= customer.get("id").asLong() == id;
+ }
+ assertThat(listed).isTrue();
+
+ JsonNode deleted = assertSuccess(support.delete(jwt, URL + "/" + id), 200);
+ assertThat(deleted.get("data") == null || deleted.get("data").isNull()).isTrue();
+ assertError(support.get(jwt, URL + "/" + id), 404);
+ }
+
+ @Test
+ void _02_ShouldStoreNullOptionalFields_WhenTheyAreAbsentEmptyOrBlank() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+
+ long absent = idOf(support.post(jwt, URL, minimalBody(unique())));
+ long blank = idOf(support.post(jwt, URL, body(unique(), "Martin", " ", "", " ")));
+
+ for (long id : List.of(absent, blank)) {
+ JsonNode data = assertSuccess(support.get(jwt, URL + "/" + id), 200).get("data");
+ assertThat(data.get("telephone").isNull()).isTrue();
+ assertThat(data.get("email").isNull()).isTrue();
+ assertThat(data.get("address").isNull()).isTrue();
+ assertThat(count("SELECT COUNT(*) FROM customers WHERE id = ? AND telephone IS NULL AND email IS NULL "
+ + "AND address IS NULL", id)).isEqualTo(1L);
+ }
+ }
+
+ @Test
+ void _03_ShouldClearOptionalFields_WhenUpdatedWithoutThem() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ String name = unique();
+ long id = idOf(support.post(jwt, URL, body(name, "Martin", "555", "a@example.com", "1 Main")));
+
+ assertSuccess(support.put(jwt, URL + "/" + id, minimalBody(name)), 200);
+
+ assertThat(count("SELECT COUNT(*) FROM customers WHERE id = ? AND telephone IS NULL AND email IS NULL "
+ + "AND address IS NULL", id)).isEqualTo(1L);
+ }
+
+ @Test
+ void _04_ShouldReturn400OrHandOverTo422_WhenEmailIsMalformed() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ String name = unique();
+
+ // Refused by Bean Validation (@Email) before the service runs: 400 naming the field
+ for (String bad : List.of("no-at-sign", "a@@b.com", "a b@c.com", "@c.com")) {
+ JsonNode json = assertError(support.post(jwt, URL, body(name, "Martin", null, bad, null)), 400);
+ assertThat(json.get("message").asText()).contains("email: ");
+ }
+ // The Bean Validation rule accepts a domain without a dot, the service rule then refuses it: 422
+ JsonNode noDot = assertError(support.post(jwt, URL, body(name, "Martin", null, "alice@example", null)), 422);
+ assertThat(noDot.get("message").asText()).isEqualTo("Email is not valid");
+ JsonNode onUpdate = assertError(support.put(jwt, URL + "/" + idOf(support.post(jwt, URL, minimalBody(name))),
+ body(name, "Martin", null, "alice@example", null)), 422);
+ assertThat(onUpdate.get("message").asText()).isEqualTo("Email is not valid");
+ assertThat(count("SELECT COUNT(*) FROM customers WHERE first_name = ? AND email IS NOT NULL", name)).isZero();
+ }
+
+ @Test
+ void _05_ShouldReturn400NamingTheFieldAndStoreNothing_WhenBodyIsInvalid() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ String name = unique();
+
+ JsonNode blank = assertError(support.post(jwt, URL, body(" ", "Martin", null, null, null)), 400);
+ JsonNode oversize = assertError(support.post(jwt, URL, body(name, "y".repeat(101), null, null, null)), 400);
+ JsonNode phone = assertError(support.post(jwt, URL, body(name, "Martin", "1".repeat(31), null, null)), 400);
+
+ assertThat(blank.get("message").asText()).contains("firstName: ");
+ assertThat(oversize.get("message").asText()).contains("lastName: ");
+ assertThat(phone.get("message").asText()).contains("telephone: ");
+ assertThat(count("SELECT COUNT(*) FROM customers WHERE first_name = ?", name)).isZero();
+ }
+
+ @Test
+ void _06_ShouldReturn404_WhenCustomerDoesNotExist() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+
+ assertError(support.get(jwt, URL + "/999999999"), 404);
+ assertError(support.put(jwt, URL + "/999999999", minimalBody(unique())), 404);
+ assertError(support.delete(jwt, URL + "/999999999"), 404);
+ }
+
+ @Test
+ void _07_ShouldRefuseTheDeleteWith422AndKeepTheCustomer_WhenAnOrderReferencesIt() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ long id = idOf(support.post(jwt, URL, minimalBody(unique())));
+ // The order endpoints do not exist yet: a category, a product and the order are inserted through SQL
+ long categoryId = jdbcTemplate.queryForObject(
+ "INSERT INTO categories (category_name) VALUES (?) RETURNING id", Long.class, unique());
+ long productId = jdbcTemplate.queryForObject(
+ "INSERT INTO products (category_id, product_name, unit_price) VALUES (?, ?, 5.00) RETURNING id",
+ Long.class, categoryId, unique());
+ jdbcTemplate.update("INSERT INTO orders (customer_id, product_id, quantity, total) VALUES (?, ?, 2, 10.00)",
+ id, productId);
+
+ JsonNode refused = assertError(support.delete(jwt, URL + "/" + id), 422);
+
+ assertThat(refused.get("message").asText()).isEqualTo("Customer is referenced by orders");
+ assertSuccess(support.get(jwt, URL + "/" + id), 200);
+ assertThat(count("SELECT COUNT(*) FROM customers WHERE id = ?", id)).isEqualTo(1L);
+
+ // Once the order is gone the same delete goes through
+ jdbcTemplate.update("DELETE FROM orders WHERE customer_id = ?", id);
+ assertSuccess(support.delete(jwt, URL + "/" + id), 200);
+ assertError(support.get(jwt, URL + "/" + id), 404);
+ }
+
+ @Test
+ void _08_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 _09_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception {
+ String jwt = support.createAdmin().getJwt();
+ idOf(support.post(jwt, URL, minimalBody(unique())));
+
+ JsonNode data = assertSuccess(support.get(jwt, URL + "?page=100000&size=10"), 200).get("data");
+
+ assertThat(data.has("content")).isTrue();
+ assertThat(data.get("content").isArray()).isTrue();
+ assertThat(data.get("content")).isEmpty();
+ assertThat(data.get("page").asInt()).isEqualTo(100000);
+ assertThat(data.get("totalElements").asLong()).isGreaterThanOrEqualTo(1L);
+ }
+
+ @Test
+ void _10_ShouldReturn401_WhenNoTokenIsSent() throws Exception {
+ for (String url : List.of(URL, URL + "/1")) {
+ assertError(support.get(null, url), 401);
+ }
+ assertError(support.post(null, URL, minimalBody(unique())), 401);
+ assertError(support.put(null, URL + "/1", minimalBody(unique())), 401);
+ assertError(support.delete(null, URL + "/1"), 401);
+ }
+
+ @Test
+ void _11_ShouldReturn403AndChangeNothing_WhenUserLacksTheWritePermission() throws Exception {
+ TestUser simple = support.createSimpleUser();
+ TestUser readOnly = support.createUser(true, false, support.createRole("CUSTOMER:READ"));
+ String admin = support.createAdmin().getJwt();
+ String existing = unique();
+ long id = idOf(support.post(admin, URL, minimalBody(existing)));
+ String name = unique();
+
+ for (TestUser user : List.of(simple, readOnly)) {
+ assertError(support.post(user.getJwt(), URL, minimalBody(name)), 403);
+ assertError(support.put(user.getJwt(), URL + "/" + id, minimalBody(name)), 403);
+ assertError(support.delete(user.getJwt(), URL + "/" + id), 403);
+ }
+ assertThat(count("SELECT COUNT(*) FROM customers WHERE first_name = ?", name)).isZero();
+ assertThat(count("SELECT COUNT(*) FROM customers WHERE id = ? AND first_name = ?", id, existing)).isEqualTo(1L);
+ // The simple user has no read permission either, the read-only user does
+ assertError(support.get(simple.getJwt(), URL), 403);
+ assertSuccess(support.get(readOnly.getJwt(), URL + "/" + id), 200);
+ }
+
+ @Test
+ void _12_ShouldAllowOnlyWhatEachPermissionGrants_WhenUsersHoldASingleCustomerPermission() throws Exception {
+ TestUser writer = support.createUser(true, false, support.createRole("CUSTOMER:WRITE"));
+
+ // CUSTOMER:WRITE alone creates customers but neither lists nor reads them
+ long id = idOf(support.post(writer.getJwt(), URL, minimalBody(unique())));
+ assertError(support.get(writer.getJwt(), URL + "/" + id), 403);
+ assertError(support.get(writer.getJwt(), URL), 403);
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCustomerMvcTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCustomerMvcTest.java
new file mode 100644
index 0000000..310f294
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCustomerMvcTest.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.customer.service.CustomerService;
+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 customer controller tests: builds MockMvc on the shared CustomerMvcTestConfig 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(CustomerMvcTestConfig.class)
+abstract class AbstractCustomerMvcTest {
+
+ @Autowired
+ private WebApplicationContext context;
+ @Autowired
+ protected CustomerService customerService;
+ @Autowired
+ protected AuthService authService;
+
+ protected MockMvc mockMvc;
+
+ @BeforeEach
+ void setUpMockMvc() {
+ reset(customerService, 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/CustomerControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerControllerTest.java
new file mode 100644
index 0000000..916c05e
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerControllerTest.java
@@ -0,0 +1,310 @@
+package com.edgareldy.springjdbctutorial.ws.mvc;
+
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError;
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.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.fasterxml.jackson.databind.JsonNode;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+
+/**
+ * MockMvc tests of CustomerController with CustomerService mocked but the real security and validation: statuses,
+ * ApiResponse shape, page/size bounds, per-field body validation and the 404/422 refusals.
+ *
+ * Created edgar.muhamyangabo on 9/20/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/20/26
+ * Project : spring-jdbc-tutorial
+ */
+class CustomerControllerTest extends AbstractCustomerMvcTest {
+
+ private static final String READ = "CUSTOMER:READ";
+ private static final String WRITE = "CUSTOMER:WRITE";
+ private static final String URL = "/api/v1/customers";
+ private static final String VALID_BODY = "{\"firstName\":\"Alice\",\"lastName\":\"Martin\","
+ + "\"telephone\":\"555\",\"email\":\"alice@example.com\",\"address\":\"1 Main Street\"}";
+ private static final CustomerDto ALICE = new CustomerDto(1L, "Alice", "Martin", "555", "alice@example.com", "1 Main Street");
+
+ private static String body(String firstName, String lastName, String email) {
+ return "{\"firstName\":\"" + firstName + "\",\"lastName\":\"" + lastName + "\",\"email\":\"" + email + "\"}";
+ }
+
+ // ---------------------------------------------------------------- GET /customers
+
+ @Test
+ void _01_ShouldReturn200WithPageAndDefaults_WhenTokenHoldsCustomerRead() throws Exception {
+ when(customerService.list(0, 20)).thenReturn(new PageDto<>(List.of(ALICE), 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("firstName").asText()).isEqualTo("Alice");
+ assertThat(first.get("lastName").asText()).isEqualTo("Martin");
+ assertThat(first.get("telephone").asText()).isEqualTo("555");
+ assertThat(first.get("email").asText()).isEqualTo("alice@example.com");
+ assertThat(first.get("address").asText()).isEqualTo("1 Main Street");
+ assertThat(data.get("page").asInt()).isZero();
+ assertThat(data.get("size").asInt()).isEqualTo(20);
+ assertThat(data.get("totalElements").asLong()).isEqualTo(1L);
+ assertThat(data.get("totalPages").asInt()).isEqualTo(1);
+ }
+
+ @Test
+ void _02_ShouldPassPageAndSizeToTheService_WhenTheyAreGiven() throws Exception {
+ when(customerService.list(2, 5)).thenReturn(new PageDto<>(List.of(), 2, 5, 11));
+
+ JsonNode json = assertSuccess(call(getWith(URL + "?page=2&size=5", token(READ))), 200);
+
+ assertThat(json.get("data").get("page").asInt()).isEqualTo(2);
+ assertThat(json.get("data").get("size").asInt()).isEqualTo(5);
+ verify(customerService).list(2, 5);
+ }
+
+ @Test
+ void _03_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception {
+ when(customerService.list(99, 10)).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 _04_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(customerService);
+ }
+
+ @Test
+ void _05_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(customerService);
+ }
+
+ @Test
+ void _06_ShouldReturn400NamingBoth_WhenPageAndSizeAreInvalid() throws Exception {
+ JsonNode json = assertError(call(getWith(URL + "?page=-5&size=500", token(READ))), 400);
+
+ assertThat(json.get("message").asText()).contains("page: ").contains("size: ");
+ }
+
+ @Test
+ void _07_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() throws Exception {
+ when(customerService.list(0, 1)).thenReturn(new PageDto<>(List.of(), 0, 1, 0));
+ when(customerService.list(0, 100)).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 /customers/{id}
+
+ @Test
+ void _08_ShouldReturn200WithNullOptionalFields_WhenCustomerHasNoOptionalData() throws Exception {
+ when(customerService.get(2L)).thenReturn(new CustomerDto(2L, "Bob", "Stone", null, null, null));
+
+ JsonNode json = assertSuccess(call(getWith(URL + "/2", token(READ))), 200);
+
+ assertThat(json.get("data").get("id").asLong()).isEqualTo(2L);
+ assertThat(json.get("data").get("firstName").asText()).isEqualTo("Bob");
+ assertThat(json.get("data").get("telephone").isNull()).isTrue();
+ assertThat(json.get("data").get("email").isNull()).isTrue();
+ assertThat(json.get("data").get("address").isNull()).isTrue();
+ }
+
+ @Test
+ void _09_ShouldReturn404ApiResponse_WhenCustomerDoesNotExist() throws Exception {
+ when(customerService.get(99L)).thenThrow(new ResourceNotFoundException("Customer not found: 99"));
+
+ JsonNode json = assertError(call(getWith(URL + "/99", token(READ))), 404);
+
+ assertThat(json.get("message").asText()).isEqualTo("Customer not found: 99");
+ }
+
+ @Test
+ void _10_ShouldReturn400_WhenCustomerIdIsNotANumber() throws Exception {
+ assertError(call(getWith(URL + "/abc", token(READ))), 400);
+ verifyNoInteractions(customerService);
+ }
+
+ // ---------------------------------------------------------------- POST /customers
+
+ @Test
+ void _11_ShouldReturn201WithCreatedCustomer_WhenBodyIsValid() throws Exception {
+ when(customerService.create(any(CustomerDto.class))).thenReturn(ALICE);
+
+ JsonNode json = assertSuccess(call(postJson(URL, token(WRITE), VALID_BODY)), 201);
+
+ assertThat(json.get("data").get("id").asLong()).isEqualTo(1L);
+ assertThat(json.get("message").asText()).isEqualTo("Customer created");
+ ArgumentCaptor sent = ArgumentCaptor.forClass(CustomerDto.class);
+ verify(customerService).create(sent.capture());
+ assertThat(sent.getValue()).isEqualTo(new CustomerDto(null, "Alice", "Martin", "555", "alice@example.com", "1 Main Street"));
+ }
+
+ @Test
+ void _12_ShouldReturn201_WhenOptionalFieldsAreAbsentOrEmpty() throws Exception {
+ when(customerService.create(any(CustomerDto.class))).thenReturn(new CustomerDto(3L, "Bob", "Stone", null, null, null));
+
+ assertSuccess(call(postJson(URL, token(WRITE), "{\"firstName\":\"Bob\",\"lastName\":\"Stone\"}")), 201);
+ assertSuccess(call(postJson(URL, token(WRITE),
+ "{\"firstName\":\"Bob\",\"lastName\":\"Stone\",\"telephone\":\"\",\"email\":\"\",\"address\":\"\"}")), 201);
+ }
+
+ @Test
+ void _13_ShouldReturn400NamingTheField_WhenFirstOrLastNameIsBlankOrMissing() throws Exception {
+ JsonNode first = assertError(call(postJson(URL, token(WRITE), body(" ", "Martin", "a@b.co"))), 400);
+ JsonNode last = assertError(call(postJson(URL, token(WRITE), body("Alice", "", "a@b.co"))), 400);
+ JsonNode missing = assertError(call(postJson(URL, token(WRITE), "{}")), 400);
+
+ assertThat(first.get("message").asText()).contains("firstName: ").doesNotContain("lastName: ");
+ assertThat(last.get("message").asText()).contains("lastName: ").doesNotContain("firstName: ");
+ assertThat(missing.get("message").asText()).contains("firstName: ").contains("lastName: ");
+ verifyNoInteractions(customerService);
+ }
+
+ @Test
+ void _14_ShouldReturn400NamingTheField_WhenEmailIsMalformed() throws Exception {
+ JsonNode json = assertError(call(postJson(URL, token(WRITE), body("Alice", "Martin", "not-an-email"))), 400);
+
+ assertThat(json.get("message").asText()).contains("email: ");
+ verifyNoInteractions(customerService);
+ }
+
+ @Test
+ void _15_ShouldReturn400NamingTheField_WhenAFieldIsOversize() throws Exception {
+ String longName = "x".repeat(101);
+ JsonNode first = assertError(call(postJson(URL, token(WRITE), body(longName, "Martin", ""))), 400);
+ JsonNode last = assertError(call(postJson(URL, token(WRITE), body("Alice", longName, ""))), 400);
+ JsonNode phone = assertError(call(postJson(URL, token(WRITE),
+ "{\"firstName\":\"A\",\"lastName\":\"B\",\"telephone\":\"" + "1".repeat(31) + "\"}")), 400);
+ JsonNode address = assertError(call(postJson(URL, token(WRITE),
+ "{\"firstName\":\"A\",\"lastName\":\"B\",\"address\":\"" + "a".repeat(256) + "\"}")), 400);
+ JsonNode email = assertError(call(postJson(URL, token(WRITE),
+ body("Alice", "Martin", "a".repeat(250) + "@b.com"))), 400);
+
+ assertThat(first.get("message").asText()).contains("firstName: ");
+ assertThat(last.get("message").asText()).contains("lastName: ");
+ assertThat(phone.get("message").asText()).contains("telephone: ");
+ assertThat(address.get("message").asText()).contains("address: ");
+ assertThat(email.get("message").asText()).contains("email: ");
+ verifyNoInteractions(customerService);
+ }
+
+ @Test
+ void _16_ShouldReturn201_WhenFieldsSitExactlyOnTheirLimits() throws Exception {
+ when(customerService.create(any(CustomerDto.class))).thenReturn(ALICE);
+ String limits = "{\"firstName\":\"" + "x".repeat(100) + "\",\"lastName\":\"" + "y".repeat(100)
+ + "\",\"telephone\":\"" + "1".repeat(30) + "\",\"address\":\"" + "a".repeat(255) + "\"}";
+
+ assertSuccess(call(postJson(URL, token(WRITE), limits)), 201);
+ }
+
+ @Test
+ void _17_ShouldReturn400_WhenBodyIsMalformedJson() throws Exception {
+ assertError(call(postJson(URL, token(WRITE), "{\"firstName\":")), 400);
+ verifyNoInteractions(customerService);
+ }
+
+ @Test
+ void _18_ShouldReturn422_WhenServiceRefusesTheEmailFormat() throws Exception {
+ when(customerService.create(any(CustomerDto.class))).thenThrow(new BusinessRuleException("Email is not valid"));
+
+ JsonNode json = assertError(call(postJson(URL, token(WRITE), body("Alice", "Martin", "a@b"))), 422);
+
+ assertThat(json.get("message").asText()).isEqualTo("Email is not valid");
+ }
+
+ @Test
+ void _19_ShouldReturn415_WhenContentTypeIsNotJson() throws Exception {
+ assertError(call(MockMvcRequestBuilders.post(URL)
+ .header("Authorization", bearer(token(WRITE))).contentType(MediaType.TEXT_PLAIN).content("Alice")), 415);
+ verifyNoInteractions(customerService);
+ }
+
+ // ---------------------------------------------------------------- PUT /customers/{id}
+
+ @Test
+ void _20_ShouldReturn200WithUpdatedCustomer_WhenBodyIsValid() throws Exception {
+ when(customerService.update(eq(1L), any(CustomerDto.class))).thenReturn(ALICE);
+
+ JsonNode json = assertSuccess(call(putJson(URL + "/1", token(WRITE), VALID_BODY)), 200);
+
+ assertThat(json.get("data").get("firstName").asText()).isEqualTo("Alice");
+ assertThat(json.get("message").asText()).isEqualTo("Customer updated");
+ }
+
+ @Test
+ void _21_ShouldReturn404ApiResponse_WhenUpdatingAnUnknownCustomer() throws Exception {
+ when(customerService.update(eq(99L), any(CustomerDto.class)))
+ .thenThrow(new ResourceNotFoundException("Customer not found: 99"));
+
+ assertError(call(putJson(URL + "/99", token(WRITE), VALID_BODY)), 404);
+ }
+
+ @Test
+ void _22_ShouldReturn400_WhenUpdatedBodyIsInvalid() throws Exception {
+ JsonNode json = assertError(call(putJson(URL + "/1", token(WRITE), body("", "Martin", "bad"))), 400);
+
+ assertThat(json.get("message").asText()).contains("firstName: ").contains("email: ");
+ verifyNoInteractions(customerService);
+ }
+
+ // ---------------------------------------------------------------- DELETE /customers/{id}
+
+ @Test
+ void _23_ShouldReturn200WithoutData_WhenCustomerIsDeleted() throws Exception {
+ JsonNode json = assertSuccess(call(deleteWith(URL + "/2", token(WRITE))), 200);
+
+ assertThat(json.get("message").asText()).isEqualTo("Customer deleted");
+ assertThat(json.get("data") == null || json.get("data").isNull()).isTrue();
+ verify(customerService).delete(2L);
+ }
+
+ @Test
+ void _24_ShouldReturn404ApiResponse_WhenDeletingAnUnknownCustomer() throws Exception {
+ doThrow(new ResourceNotFoundException("Customer not found: 99")).when(customerService).delete(99L);
+
+ assertError(call(deleteWith(URL + "/99", token(WRITE))), 404);
+ }
+
+ @Test
+ void _25_ShouldReturn422_WhenCustomerIsReferencedByOrders() throws Exception {
+ doThrow(new BusinessRuleException("Customer is referenced by orders")).when(customerService).delete(2L);
+
+ JsonNode json = assertError(call(deleteWith(URL + "/2", token(WRITE))), 422);
+
+ assertThat(json.get("message").asText()).isEqualTo("Customer is referenced by orders");
+ }
+
+ // ---------------------------------------------------------------- unsupported method
+
+ @Test
+ void _26_ShouldReturn405ApiResponse_WhenPatchIsUsedOnACustomer() throws Exception {
+ assertError(call(patchWith(URL + "/2", token(WRITE))), 405);
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerEndpointSecurityTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerEndpointSecurityTest.java
new file mode 100644
index 0000000..c041293
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerEndpointSecurityTest.java
@@ -0,0 +1,117 @@
+package com.edgareldy.springjdbctutorial.ws.mvc;
+
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.common.dto.PageDto;
+import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto;
+import com.edgareldy.springjdbctutorial.ws.security.JwtService;
+import com.fasterxml.jackson.databind.JsonNode;
+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 5 customer endpoints of the README table, checked through the real filter chain and
+ * method security: no token gives 401, a revoked token gives 401, a token holding every permission EXCEPT the
+ * required one (or none at all) gives 403, all as ApiResponse errors and without ever reaching the (mocked)
+ * 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 CustomerEndpointSecurityTest extends AbstractCustomerMvcTest {
+
+ private static final String BODY = "{\"firstName\":\"Alice\",\"lastName\":\"Martin\"}";
+
+ // 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/customers", null, "CUSTOMER:READ"),
+ Arguments.of(HttpMethod.GET, "/api/v1/customers/1", null, "CUSTOMER:READ"),
+ Arguments.of(HttpMethod.POST, "/api/v1/customers", BODY, "CUSTOMER:WRITE"),
+ Arguments.of(HttpMethod.PUT, "/api/v1/customers/1", BODY, "CUSTOMER:WRITE"),
+ Arguments.of(HttpMethod.DELETE, "/api/v1/customers/1", null, "CUSTOMER: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(customerService);
+ }
+
+ @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(customerService);
+ }
+
+ @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(customerService);
+ }
+
+ @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(customerService);
+ }
+
+ @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.
+ CustomerDto customer = new CustomerDto(1L, "Alice", "Martin", null, null, null);
+ when(customerService.list(0, 20)).thenReturn(new PageDto(List.of(), 0, 20, 0));
+ when(customerService.get(1L)).thenReturn(customer);
+ when(customerService.create(any())).thenReturn(customer);
+ when(customerService.update(eq(1L), any())).thenReturn(customer);
+
+ 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/CustomerMvcTestConfig.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerMvcTestConfig.java
new file mode 100644
index 0000000..f27f0ef
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerMvcTestConfig.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.customer.service.CustomerService;
+import com.edgareldy.springjdbctutorial.ws.config.SecurityConfig;
+import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig;
+import com.edgareldy.springjdbctutorial.ws.controller.CustomerController;
+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 customer 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 CustomerService (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 CustomerMvcTestConfig 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
+ CustomerService customerService() {
+ return mock(CustomerService.class);
+ }
+
+ @Bean
+ CustomerController customerController(CustomerService customerService) {
+ return new CustomerController(customerService);
+ }
+
+ @Bean
+ GlobalExceptionHandler globalExceptionHandler() {
+ return new GlobalExceptionHandler();
+ }
+
+ @Override
+ public void extendMessageConverters(List> converters) {
+ real.extendMessageConverters(converters);
+ }
+
+ @Override
+ public Validator getValidator() {
+ return real.getValidator();
+ }
+}