From 575b81332d4f18e5a3bb2b6e26e4f4d6776bf3bc Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 01/16] feat(customer): add the Customer entity and CustomerDto Customer is the table-shaped class documented with the jakarta.persistence annotations and CustomerDto is the business shape the service exposes. The telephone, email and address are optional, so they are nullable on both sides. The two shapes stay separate classes even though their fields are identical. --- .../core/customer/dto/CustomerDto.java | 117 ++++++++++++++++ .../core/customer/entity/Customer.java | 128 ++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dto/CustomerDto.java create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/entity/Customer.java 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 + + "}"; + } +} From 0431c53f55ecf2aa4b7b1028defd6f45f85d9f00 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 02/16] feat(customer): add the customer RowMapper and entity mapper The RowMapper reads one row into a Customer and keeps the three optional columns as null when the database holds NULL. The entity mapper converts between Customer and CustomerDto in both directions. Neither queries the database or contains a business rule. --- .../core/customer/mapper/CustomerMapper.java | 31 +++++++++++++++++++ .../customer/mapper/CustomerRowMapper.java | 29 +++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapper.java create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapper.java 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")); + } +} From 378cef495b1934ca791d1f050bdb7c5268f05c85 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 03/16] feat(customer): add the customer DAO CustomerDaoImpl runs the SQL with JdbcTemplate through AbstractDao: a page ordered by id with LIMIT and OFFSET, a count, a lookup, INSERT ... RETURNING id, an update that returns the number of affected rows and a delete. Returning the affected row count lets the service detect that a row was deleted concurrently. The DAO works in entity types only and has no stereotype annotation. --- .../core/customer/dao/CustomerDao.java | 32 +++++++++ .../customer/dao/impl/CustomerDaoImpl.java | 70 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/CustomerDao.java create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImpl.java 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); + } +} From 3c3095edf6961cdb03b5cce9fca2cdf5dd611d80 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 04/16] feat(customer): implement the customer service CustomerServiceImpl trims and bounds the names, turns blank optional fields into null, checks the format of an email when one is given, and reports an update that affected no row as a not-found error. A customer referenced by orders cannot be deleted: the foreign key raises a DataIntegrityViolationException which the service turns into a business refusal, so no order code is needed yet. The service works in dto types, pages through PageDto and is transactional through its interface proxy. --- .../customer/service/CustomerService.java | 29 ++++ .../service/impl/CustomerServiceImpl.java | 148 ++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/CustomerService.java create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImpl.java 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("."); + } +} From 27485bca0462406a3b15c94829a0b1f45a91b407 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 05/16] feat(customer): wire the customer DAO and service explicitly DaoConfig and ServiceConfig declare the beans typed by interface, and ServiceConfig imports DaoConfig so importing it alone is enough. The service bean is proxied for @Transactional through its interface. --- .../core/customer/config/DaoConfig.java | 24 +++++++++++++++++ .../core/customer/config/ServiceConfig.java | 26 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/DaoConfig.java create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/ServiceConfig.java 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); + } +} From f26eeab7fab005930d3f17811d9dc1685f9ead61 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 06/16] feat(ws): add the customer payloads and converter CustomerRequest requires the two names and bounds every field, and accepts an absent or empty optional value while checking the email format when one is given. CustomerConverter maps to and from the core dto and builds the PageResponse. Payload classes stay distinct from core dto classes. --- .../ws/converter/CustomerConverter.java | 47 +++++++ .../ws/payload/customer/CustomerRequest.java | 110 ++++++++++++++++ .../ws/payload/customer/CustomerResponse.java | 117 ++++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CustomerConverter.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerResponse.java 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 + + "}"; + } +} From 53a00aa6717f741bcf3cd9fe9b5baf48518de8d7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 07/16] feat(ws): expose the customer endpoints CustomerController follows the README table with declarative @PreAuthorize("hasPermission('CUSTOMER', 'READ'|'WRITE')"), calls only CustomerService and answers with an ApiResponse. The list validates page and size, and a creation returns 201. There is no business logic in the controller. --- .../ws/controller/CustomerController.java | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CustomerController.java 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"); + } +} From b5f559d713918a00f53ce9f9dfdafffd5e0c8a5f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 08/16] feat(ws): import the customer module into the shared context The customer ServiceConfig is added to the WebMvcConfig import list by fully qualified name, because every core module has a class with that simple name. Its DaoConfig comes along through the ServiceConfig import. --- .../edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java index 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 { From 6c7c8e8a171339ae0d47d3b4ed1db3e7560a683e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 09/16] build(customer): add Testcontainers test dependencies to the customer module The customer DAO tests run against a real PostgreSQL, so the module declares the Testcontainers JUnit and PostgreSQL artifacts and the common test-jar holding the shared singleton container, all with test scope, like the auth and catalog modules. --- core/customer/pom.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 + From bebafab43dfb0a78deda375e2bdb61a0efcf25fb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 10/16] test(customer): cover the customer DAO against PostgreSQL The DAO test loads its own dedicated fixture, which truncates the tables it touches and loads customers, one of them with NULL optional columns. It checks CRUD, the NULL round trip, page ordering, offset and an out-of-range page, the count, an update returning one affected row for a known id and zero for an unknown one, and a delete blocked by the orders foreign key. The order that blocks the delete is inserted by SQL in the test, so the constraint is proven before the order module exists. --- .../dao/impl/CustomerDaoImplTest.java | 152 ++++++++++++++++++ .../test/resources/customer-dao-dataset.sql | 19 +++ 2 files changed, 171 insertions(+) create mode 100644 core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImplTest.java create mode 100644 core/customer/src/test/resources/customer-dao-dataset.sql 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/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; From 47acf9c6540131100ff5cf5f418accba365bfaee Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 11/16] test(customer): test the customer RowMapper and entity mapper The RowMapper is fed a mocked ResultSet, including NULL telephone, email and address, and the entity mapper is checked with hand-built objects and no mocks. --- .../customer/mapper/CustomerMapperTest.java | 53 +++++++++++++++++ .../mapper/CustomerRowMapperTest.java | 58 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapperTest.java create mode 100644 core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapperTest.java 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(); + } +} From 993ac0e8990d4cdd7c07a9a2d4aee3bdfb94964e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 12/16] test(customer): unit test the customer service With a mocked DAO the tests cover the page bounds, the 404 cases, trimming, blank optional fields becoming null, the length limits at their exact boundaries, every email format case, an update that affects no row, and the foreign key race on delete becoming a business refusal. --- .../service/impl/CustomerServiceImplTest.java | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImplTest.java 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"); + } +} From 2a6ede55141689aebe0771ffc7b9fc9cac785877 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 13/16] test(ws): test the customer controller with MockMvc The real security configuration and a real signed JWT are used with a mocked service. Every endpoint is checked for status and JSON shape, NULL optional fields in the response, optional fields absent or empty, validation errors per field, page and size violations, an out-of-range page keeping content as an empty array, and the 404 and 422 mappings. --- .../ws/mvc/AbstractCustomerMvcTest.java | 92 ++++++ .../ws/mvc/CustomerControllerTest.java | 310 ++++++++++++++++++ .../ws/mvc/CustomerMvcTestConfig.java | 80 +++++ 3 files changed, 482 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCustomerMvcTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerControllerTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerMvcTestConfig.java 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/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(); + } +} From 61688796c19de1db9a48a7a0f99acdbad5a9c0fa Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 14/16] test(ws): check the permission matrix of the customer endpoints A parameterized test asserts, for each of the five endpoints, that no token or a revoked one gives 401, that a token holding every other permission or none gives 403, and that a token holding only the required CUSTOMER permission succeeds. --- .../ws/mvc/CustomerEndpointSecurityTest.java | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerEndpointSecurityTest.java 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); + } +} From 34f99c411369be16a7a657d1b849cc470da3fa72 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 15/16] test(ws): run the customer flows on the real stack Real services and a real database prove the CRUD, NULL optional fields on create and update, the two email failure modes (400 from Bean Validation, 422 from the service), the 404 cases and the pagination bounds. A customer with an order, inserted by SQL, cannot be deleted: the answer is 422 "Customer is referenced by orders" and the customer stays, then the delete succeeds once the order is gone. Permission-less and write-only users get 403. --- .../CustomerFlowIntegrationTest.java | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CustomerFlowIntegrationTest.java 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); + } +} From 4792b5e5a46cc412a61ebc6da9c0c45d02bc08e8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 16/16] docs(readme): tick the feature/customer tasks The three customer tasks are checked: the entity, dto, RowMapper, mapper, DAO, service and configuration of core/customer, the controller and converter in ws with the WebMvcConfig import, and the tests at all five layers with a dedicated fixture for the DAO test class. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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