diff --git a/README.md b/README.md index 7085ec2..6cd8409 100644 --- a/README.md +++ b/README.md @@ -421,10 +421,10 @@ Full CRUD for users, roles, and permissions, still inside `core/auth`. Assignmen ### Tasks -- [ ] `core/customer`: `Customer` entity (`@Table("customers")`) and `CustomerDto`, `CustomerRowMapper` (entity) plus `CustomerMapper` (entity ↔ dto), `CustomerDao` + impl, `CustomerService` + impl, `DaoConfig`/`ServiceConfig` -- [ ] `ws`: `CustomerController`, `CustomerConverter` -- [ ] `WebMvcConfig` updated to import `core/customer`'s config -- [ ] Tests at all five layers, `CustomerDao` with its own fixture dataset +- [x] `core/customer`: `Customer` entity (`@Table("customers")`) and `CustomerDto`, `CustomerRowMapper` (entity) plus `CustomerMapper` (entity ↔ dto), `CustomerDao` + impl, `CustomerService` + impl, `DaoConfig`/`ServiceConfig` +- [x] `ws`: `CustomerController`, `CustomerConverter` +- [x] `WebMvcConfig` updated to import `core/customer`'s config +- [x] Tests at all five layers, `CustomerDao` with its own fixture dataset ## feature/order diff --git a/core/customer/pom.xml b/core/customer/pom.xml index 227da5e..4b46f4f 100644 --- a/core/customer/pom.xml +++ b/core/customer/pom.xml @@ -19,5 +19,22 @@ com.edgareldy.springjdbctutorial common + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/DaoConfig.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/DaoConfig.java new file mode 100644 index 0000000..93ad4f5 --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/DaoConfig.java @@ -0,0 +1,24 @@ +package com.edgareldy.springjdbctutorial.core.customer.config; + +import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao; +import com.edgareldy.springjdbctutorial.core.customer.dao.impl.CustomerDaoImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Declares the DAO beans of the customer module, typed by interface (no stereotype on the implementations). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +public class DaoConfig { + + @Bean + public CustomerDao customerDao(JdbcTemplate jdbcTemplate) { + return new CustomerDaoImpl(jdbcTemplate); + } +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/ServiceConfig.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/ServiceConfig.java new file mode 100644 index 0000000..96f4f0c --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/ServiceConfig.java @@ -0,0 +1,26 @@ +package com.edgareldy.springjdbctutorial.core.customer.config; + +import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao; +import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService; +import com.edgareldy.springjdbctutorial.core.customer.service.impl.CustomerServiceImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * Declares the service beans of the customer module. Imports its DaoConfig so importing this class alone is enough. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +@Import(DaoConfig.class) +public class ServiceConfig { + + @Bean + public CustomerService customerService(CustomerDao customerDao) { + return new CustomerServiceImpl(customerDao); + } +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/CustomerDao.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/CustomerDao.java new file mode 100644 index 0000000..6245a3e --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/CustomerDao.java @@ -0,0 +1,32 @@ +package com.edgareldy.springjdbctutorial.core.customer.dao; + +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; + +import java.util.List; +import java.util.Optional; + +/** + * Data access for customers. Entity types only. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface CustomerDao { + + /** One page of customers ordered by id. */ + List findPage(int page, int size); + + long countAll(); + + Optional findById(Long id); + + /** Inserts the customer and returns it with its generated id. */ + Customer insert(Customer customer); + + /** Returns the number of rows affected, 0 when the customer no longer exists. */ + int update(Customer customer); + + void delete(Long id); +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImpl.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImpl.java new file mode 100644 index 0000000..957a1ad --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImpl.java @@ -0,0 +1,70 @@ +package com.edgareldy.springjdbctutorial.core.customer.dao.impl; + +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao; +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; +import com.edgareldy.springjdbctutorial.core.customer.mapper.CustomerRowMapper; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Optional; + +/** + * JdbcTemplate implementation of CustomerDao. Declared by @Bean in DaoConfig, no stereotype. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerDaoImpl extends AbstractDao implements CustomerDao { + + private static final String COLUMNS = "id, first_name, last_name, telephone, email, address"; + + private final CustomerRowMapper rowMapper = new CustomerRowMapper(); + + public CustomerDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public List findPage(int page, int size) { + return getJdbcTemplate().query( + "SELECT " + COLUMNS + " FROM customers ORDER BY id LIMIT ? OFFSET ?", + rowMapper, size, (long) page * size); + } + + @Override + public long countAll() { + return count("SELECT COUNT(*) FROM customers"); + } + + @Override + public Optional findById(Long id) { + return findOne("SELECT " + COLUMNS + " FROM customers WHERE id = ?", rowMapper, id); + } + + @Override + public Customer insert(Customer customer) { + long id = insertReturningId( + "INSERT INTO customers (first_name, last_name, telephone, email, address) " + + "VALUES (?, ?, ?, ?, ?) RETURNING id", + customer.getFirstName(), customer.getLastName(), customer.getTelephone(), + customer.getEmail(), customer.getAddress()); + customer.setId(id); + return customer; + } + + @Override + public int update(Customer customer) { + return getJdbcTemplate().update( + "UPDATE customers SET first_name = ?, last_name = ?, telephone = ?, email = ?, address = ? WHERE id = ?", + customer.getFirstName(), customer.getLastName(), customer.getTelephone(), + customer.getEmail(), customer.getAddress(), customer.getId()); + } + + @Override + public void delete(Long id) { + getJdbcTemplate().update("DELETE FROM customers WHERE id = ?", id); + } +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dto/CustomerDto.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dto/CustomerDto.java new file mode 100644 index 0000000..3a87492 --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dto/CustomerDto.java @@ -0,0 +1,117 @@ +package com.edgareldy.springjdbctutorial.core.customer.dto; + +import java.util.Objects; + +/** + * Business shape of a customer. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerDto { + + private Long id; + + private String firstName; + + private String lastName; + + private String telephone; + + private String email; + + private String address; + + public CustomerDto() { + } + + public CustomerDto(Long id, String firstName, String lastName, String telephone, String email, String address) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.telephone = telephone; + this.email = email; + this.address = address; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getTelephone() { + return telephone; + } + + public void setTelephone(String telephone) { + this.telephone = telephone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CustomerDto that = (CustomerDto) o; + return Objects.equals(id, that.id) && Objects.equals(firstName, that.firstName) + && Objects.equals(lastName, that.lastName) && Objects.equals(telephone, that.telephone) + && Objects.equals(email, that.email) && Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, telephone, email, address); + } + + @Override + public String toString() { + return "CustomerDto{" + + "id=" + id + + ", firstName=" + firstName + + ", lastName=" + lastName + + ", telephone=" + telephone + + ", email=" + email + + ", address=" + address + + "}"; + } +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/entity/Customer.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/entity/Customer.java new file mode 100644 index 0000000..0595080 --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/entity/Customer.java @@ -0,0 +1,128 @@ +package com.edgareldy.springjdbctutorial.core.customer.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.Objects; + +/** + * Row of the customers table. The jakarta.persistence annotations are documentation only, nothing reads them at runtime. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "customers") +public class Customer { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "first_name") + private String firstName; + + @Column(name = "last_name") + private String lastName; + + @Column(name = "telephone") + private String telephone; + + @Column(name = "email") + private String email; + + @Column(name = "address") + private String address; + + public Customer() { + } + + public Customer(Long id, String firstName, String lastName, String telephone, String email, String address) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.telephone = telephone; + this.email = email; + this.address = address; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getTelephone() { + return telephone; + } + + public void setTelephone(String telephone) { + this.telephone = telephone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Customer that = (Customer) o; + return Objects.equals(id, that.id) && Objects.equals(firstName, that.firstName) + && Objects.equals(lastName, that.lastName) && Objects.equals(telephone, that.telephone) + && Objects.equals(email, that.email) && Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, telephone, email, address); + } + + @Override + public String toString() { + return "Customer{" + + "id=" + id + + ", firstName=" + firstName + + ", lastName=" + lastName + + ", telephone=" + telephone + + ", email=" + email + + ", address=" + address + + "}"; + } +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapper.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapper.java new file mode 100644 index 0000000..e30a623 --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapper.java @@ -0,0 +1,31 @@ +package com.edgareldy.springjdbctutorial.core.customer.mapper; + +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; + +/** + * Converts between the Customer entity and the CustomerDto. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerMapper { + + public CustomerDto toDto(Customer customer) { + if (customer == null) { + return null; + } + return new CustomerDto(customer.getId(), customer.getFirstName(), customer.getLastName(), + customer.getTelephone(), customer.getEmail(), customer.getAddress()); + } + + public Customer toEntity(CustomerDto dto) { + if (dto == null) { + return null; + } + return new Customer(dto.getId(), dto.getFirstName(), dto.getLastName(), + dto.getTelephone(), dto.getEmail(), dto.getAddress()); + } +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapper.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapper.java new file mode 100644 index 0000000..1a18dc9 --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapper.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.customer.mapper; + +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the customers table into a Customer entity. Telephone, email and address are nullable columns. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerRowMapper implements RowMapper { + + @Override + public Customer mapRow(ResultSet rs, int rowNum) throws SQLException { + return new Customer( + rs.getLong("id"), + rs.getString("first_name"), + rs.getString("last_name"), + rs.getString("telephone"), + rs.getString("email"), + rs.getString("address")); + } +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/CustomerService.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/CustomerService.java new file mode 100644 index 0000000..1b531ee --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/CustomerService.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.customer.service; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; + +/** + * Business operations on customers. Dto types only at this boundary. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface CustomerService { + + /** One page of customers. Page must be >= 0 and size in 1..100, else BusinessRuleException. */ + PageDto list(int page, int size); + + /** Throws ResourceNotFoundException when the id is unknown. */ + CustomerDto get(Long id); + + /** Names are required, telephone, email and address are optional (blank becomes null). */ + CustomerDto create(CustomerDto input); + + CustomerDto update(Long id, CustomerDto input); + + /** Refused with BusinessRuleException while the customer is referenced by orders. */ + void delete(Long id); +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImpl.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImpl.java new file mode 100644 index 0000000..2891600 --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImpl.java @@ -0,0 +1,148 @@ +package com.edgareldy.springjdbctutorial.core.customer.service.impl; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao; +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; +import com.edgareldy.springjdbctutorial.core.customer.mapper.CustomerMapper; +import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +/** + * Implementation of CustomerService. Declared by @Bean in ServiceConfig (no stereotype) and returned as the interface so the @Transactional advice applies through the proxy. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerServiceImpl implements CustomerService { + + private static final int MAX_PAGE_SIZE = 100; + private static final int MAX_NAME_LENGTH = 100; + private static final int MAX_TELEPHONE_LENGTH = 30; + private static final int MAX_EMAIL_LENGTH = 255; + private static final int MAX_ADDRESS_LENGTH = 255; + + private final CustomerDao customerDao; + private final CustomerMapper mapper = new CustomerMapper(); + + public CustomerServiceImpl(CustomerDao customerDao) { + this.customerDao = customerDao; + } + + @Override + @Transactional(readOnly = true) + public PageDto list(int page, int size) { + if (page < 0) { + throw new BusinessRuleException("Page must be greater than or equal to 0"); + } + if (size < 1 || size > MAX_PAGE_SIZE) { + throw new BusinessRuleException("Size must be between 1 and " + MAX_PAGE_SIZE); + } + List content = new ArrayList<>(); + for (Customer customer : customerDao.findPage(page, size)) { + content.add(mapper.toDto(customer)); + } + return new PageDto<>(content, page, size, customerDao.countAll()); + } + + @Override + @Transactional(readOnly = true) + public CustomerDto get(Long id) { + return mapper.toDto(load(id)); + } + + @Override + @Transactional + public CustomerDto create(CustomerDto input) { + return mapper.toDto(customerDao.insert(clean(null, input))); + } + + @Override + @Transactional + public CustomerDto update(Long id, CustomerDto input) { + load(id); + Customer customer = clean(id, input); + if (customerDao.update(customer) == 0) { + // The customer was deleted between the load and the update + throw new ResourceNotFoundException("Customer not found: " + id); + } + return mapper.toDto(customer); + } + + @Override + @Transactional + public void delete(Long id) { + load(id); + try { + customerDao.delete(id); + } catch (DataIntegrityViolationException e) { + // The foreign key from orders refused the delete + throw new BusinessRuleException("Customer is referenced by orders", e); + } + } + + private Customer load(Long id) { + return customerDao.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Customer not found: " + id)); + } + + private static Customer clean(Long id, CustomerDto input) { + String firstName = required(input.getFirstName(), "First name", MAX_NAME_LENGTH); + String lastName = required(input.getLastName(), "Last name", MAX_NAME_LENGTH); + String telephone = optional(input.getTelephone(), "Telephone", MAX_TELEPHONE_LENGTH); + String email = optional(input.getEmail(), "Email", MAX_EMAIL_LENGTH); + if (email != null && !isValidEmail(email)) { + throw new BusinessRuleException("Email is not valid"); + } + String address = optional(input.getAddress(), "Address", MAX_ADDRESS_LENGTH); + return new Customer(id, firstName, lastName, telephone, email, address); + } + + private static String required(String raw, String label, int max) { + String value = raw == null ? "" : raw.trim(); + if (value.isEmpty()) { + throw new BusinessRuleException(label + " must not be blank"); + } + return checkLength(value, label, max); + } + + /** Trims, turns blank into null, then checks the length. */ + private static String optional(String raw, String label, int max) { + String value = raw == null ? "" : raw.trim(); + if (value.isEmpty()) { + return null; + } + return checkLength(value, label, max); + } + + private static String checkLength(String value, String label, int max) { + if (value.length() > max) { + throw new BusinessRuleException(label + " must be at most " + max + " characters"); + } + return value; + } + + /** Basic format: no whitespace, one @, a non-empty local part and a domain containing a dot. */ + private static boolean isValidEmail(String email) { + for (int i = 0; i < email.length(); i++) { + if (Character.isWhitespace(email.charAt(i))) { + return false; + } + } + int at = email.indexOf('@'); + if (at < 1 || at != email.lastIndexOf('@')) { + return false; + } + String domain = email.substring(at + 1); + int dot = domain.indexOf('.'); + return dot > 0 && !domain.endsWith("."); + } +} diff --git a/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImplTest.java b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImplTest.java new file mode 100644 index 0000000..a1db80e --- /dev/null +++ b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImplTest.java @@ -0,0 +1,152 @@ +package com.edgareldy.springjdbctutorial.core.customer.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.core.customer.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao; +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests CustomerDaoImpl against the real PostgreSQL, with its own fixture file customer-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Testcontainers starts a throw-away PostgreSQL in Docker (shared by every test class of the JVM through +// PostgresTestContainer); @DynamicPropertySource injects its url into the Spring Environment before the context +// is built, so DataSourceConfig connects to it and Flyway migrates the REAL schema. @Sql runs this class's own +// fixture file before EACH test so every test starts from the same rows. +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/customer-dao-dataset.sql") +class CustomerDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private CustomerDao customerDao; + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void _01_ShouldReturnEveryColumn_WhenIdExists() { + Customer customer = customerDao.findById(1L).orElseThrow(); + + assertThat(customer).isEqualTo(new Customer(1L, "Alice", "Martin", "+33 1 23 45", "alice@example.com", "1 Main Street")); + } + + @Test + void _02_ShouldReturnEmpty_WhenIdDoesNotExist() { + assertThat(customerDao.findById(999L)).isEmpty(); + } + + @Test + void _03_ShouldReadNullOptionalColumns_WhenTheyAreNullInTheRow() { + Customer customer = customerDao.findById(2L).orElseThrow(); + + assertThat(customer.getFirstName()).isEqualTo("Bob"); + assertThat(customer.getTelephone()).isNull(); + assertThat(customer.getEmail()).isNull(); + assertThat(customer.getAddress()).isNull(); + } + + @Test + void _04_ShouldReturnFirstPageOrderedById_WhenPageIsZero() { + assertThat(customerDao.findPage(0, 3)).extracting(Customer::getId).containsExactly(1L, 2L, 3L); + } + + @Test + void _05_ShouldApplyOffsetOfPageTimesSize_WhenPageIsOne() { + assertThat(customerDao.findPage(1, 3)).extracting(Customer::getId).containsExactly(4L); + } + + @Test + void _06_ShouldReturnEmptyList_WhenPageIsBeyondTheLastOne() { + assertThat(customerDao.findPage(50, 10)).isEmpty(); + } + + @Test + void _07_ShouldCountEveryRow_WhenCountingAll() { + assertThat(customerDao.countAll()).isEqualTo(4L); + } + + @Test + void _08_ShouldGenerateIdAndPersistEveryColumn_WhenCustomerIsInserted() { + Customer created = customerDao.insert(new Customer(null, "Eve", "Adams", "555", "eve@example.com", "5 Road")); + + assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + assertThat(customerDao.findById(created.getId())).contains(created); + assertThat(customerDao.countAll()).isEqualTo(5L); + } + + @Test + void _09_ShouldRoundTripNulls_WhenOptionalColumnsAreNullOnInsert() { + Customer created = customerDao.insert(new Customer(null, "Zed", "Zulu", null, null, null)); + + Customer read = customerDao.findById(created.getId()).orElseThrow(); + + assertThat(read.getTelephone()).isNull(); + assertThat(read.getEmail()).isNull(); + assertThat(read.getAddress()).isNull(); + } + + @Test + void _10_ShouldReturnOneAndChangeEveryColumn_WhenCustomerExists() { + int affected = customerDao.update(new Customer(2L, "Robert", "Stone", "111", "rob@example.com", "2 Road")); + + assertThat(affected).isEqualTo(1); + assertThat(customerDao.findById(2L)).contains(new Customer(2L, "Robert", "Stone", "111", "rob@example.com", "2 Road")); + } + + @Test + void _11_ShouldWriteNulls_WhenUpdatedOptionalColumnsAreNull() { + customerDao.update(new Customer(1L, "Alice", "Martin", null, null, null)); + + Customer read = customerDao.findById(1L).orElseThrow(); + + assertThat(read.getTelephone()).isNull(); + assertThat(read.getEmail()).isNull(); + assertThat(read.getAddress()).isNull(); + } + + @Test + void _12_ShouldReturnZeroAndChangeNothing_WhenUpdatingAnUnknownId() { + int affected = customerDao.update(new Customer(999L, "Ghost", "Nobody", null, null, null)); + + assertThat(affected).isZero(); + assertThat(customerDao.countAll()).isEqualTo(4L); + } + + @Test + void _13_ShouldDeleteCustomer_WhenNoOrderReferencesIt() { + customerDao.delete(4L); + + assertThat(customerDao.findById(4L)).isEmpty(); + assertThat(customerDao.countAll()).isEqualTo(3L); + } + + @Test + void _14_ShouldThrowDataIntegrityViolationException_WhenAnOrderStillReferencesTheCustomer() { + // The order endpoints do not exist yet: the referencing row is inserted straight through SQL + jdbcTemplate.update("INSERT INTO orders (customer_id, product_id, quantity, total) VALUES (3, 1, 2, 25.00)"); + + assertThatThrownBy(() -> customerDao.delete(3L)).isInstanceOf(DataIntegrityViolationException.class); + + assertThat(customerDao.findById(3L)).isPresent(); + } +} diff --git a/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapperTest.java b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapperTest.java new file mode 100644 index 0000000..d9ae8fa --- /dev/null +++ b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapperTest.java @@ -0,0 +1,53 @@ +package com.edgareldy.springjdbctutorial.core.customer.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; +import org.junit.jupiter.api.Test; + +/** + * Tests CustomerMapper (entity to dto and back) on hand-built objects, no mocks. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CustomerMapperTest { + + private final CustomerMapper mapper = new CustomerMapper(); + + @Test + void _01_ShouldCopyEveryField_WhenConvertingEntityToDto() { + CustomerDto dto = mapper.toDto(new Customer(3L, "Alice", "Martin", "555", "a@b.co", "Street")); + + assertThat(dto).isEqualTo(new CustomerDto(3L, "Alice", "Martin", "555", "a@b.co", "Street")); + } + + @Test + void _02_ShouldKeepNulls_WhenOptionalFieldsAreNull() { + CustomerDto dto = mapper.toDto(new Customer(3L, "Bob", "Stone", null, null, null)); + + assertThat(dto.getTelephone()).isNull(); + assertThat(dto.getEmail()).isNull(); + assertThat(dto.getAddress()).isNull(); + } + + @Test + void _03_ShouldReturnNull_WhenEntityIsNull() { + assertThat(mapper.toDto(null)).isNull(); + } + + @Test + void _04_ShouldCopyEveryField_WhenConvertingDtoToEntity() { + Customer customer = mapper.toEntity(new CustomerDto(3L, "Alice", "Martin", "555", "a@b.co", "Street")); + + assertThat(customer).isEqualTo(new Customer(3L, "Alice", "Martin", "555", "a@b.co", "Street")); + } + + @Test + void _05_ShouldReturnNull_WhenDtoIsNull() { + assertThat(mapper.toEntity(null)).isNull(); + } +} diff --git a/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapperTest.java b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapperTest.java new file mode 100644 index 0000000..2a88bff --- /dev/null +++ b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapperTest.java @@ -0,0 +1,58 @@ +package com.edgareldy.springjdbctutorial.core.customer.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +/** + * Tests CustomerRowMapper against a mocked ResultSet, without any database. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CustomerRowMapperTest { + + private final CustomerRowMapper mapper = new CustomerRowMapper(); + + // Mockito.mock(ResultSet.class) is a fake row whose getters return what the test stubs, column by + // column: the test only checks the column-to-field mapping, with no database and no driver. + @Test + void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(7L); + when(rs.getString("first_name")).thenReturn("Alice"); + when(rs.getString("last_name")).thenReturn("Martin"); + when(rs.getString("telephone")).thenReturn("555"); + when(rs.getString("email")).thenReturn("alice@example.com"); + when(rs.getString("address")).thenReturn("1 Main Street"); + + Customer customer = mapper.mapRow(rs, 0); + + assertThat(customer).isEqualTo(new Customer(7L, "Alice", "Martin", "555", "alice@example.com", "1 Main Street")); + } + + @Test + void _02_ShouldKeepNulls_WhenTelephoneEmailAndAddressAreNull() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(8L); + when(rs.getString("first_name")).thenReturn("Bob"); + when(rs.getString("last_name")).thenReturn("Stone"); + when(rs.getString("telephone")).thenReturn(null); + when(rs.getString("email")).thenReturn(null); + when(rs.getString("address")).thenReturn(null); + + Customer customer = mapper.mapRow(rs, 0); + + assertThat(customer.getId()).isEqualTo(8L); + assertThat(customer.getTelephone()).isNull(); + assertThat(customer.getEmail()).isNull(); + assertThat(customer.getAddress()).isNull(); + } +} diff --git a/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImplTest.java b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImplTest.java new file mode 100644 index 0000000..3044ac0 --- /dev/null +++ b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImplTest.java @@ -0,0 +1,300 @@ +package com.edgareldy.springjdbctutorial.core.customer.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao; +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +/** + * Unit tests of CustomerServiceImpl: the DAO is a Mockito mock, assertions are made on the dto objects. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// MockitoExtension builds the @Mock fields before each test and fails a test whose stubbing is never used +// (strict stubs), so no test keeps a stub it does not need. No Spring context and no database here. +@ExtendWith(MockitoExtension.class) +class CustomerServiceImplTest { + + @Mock + private CustomerDao customerDao; + + private CustomerServiceImpl service() { + return new CustomerServiceImpl(customerDao); + } + + private static CustomerDto input(String first, String last, String telephone, String email, String address) { + return new CustomerDto(null, first, last, telephone, email, address); + } + + private static CustomerDto withEmail(String email) { + return input("Alice", "Martin", null, email, null); + } + + private Customer created() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Customer.class); + verify(customerDao).insert(captor.capture()); + return captor.getValue(); + } + + // ---------------------------------------------------------------- list + + @Test + void _01_ShouldReturnPageOfDtos_WhenListing() { + when(customerDao.findPage(1, 2)).thenReturn(List.of(new Customer(3L, "Carol", "Diaz", null, null, null))); + when(customerDao.countAll()).thenReturn(3L); + + PageDto page = service().list(1, 2); + + assertThat(page.getContent()).containsExactly(new CustomerDto(3L, "Carol", "Diaz", null, null, null)); + assertThat(page.getPage()).isEqualTo(1); + assertThat(page.getSize()).isEqualTo(2); + assertThat(page.getTotalElements()).isEqualTo(3L); + } + + @Test + void _02_ShouldReturnEmptyContent_WhenPageIsBeyondTheLastOne() { + when(customerDao.findPage(50, 10)).thenReturn(List.of()); + when(customerDao.countAll()).thenReturn(3L); + + PageDto page = service().list(50, 10); + + assertThat(page.getContent()).isEmpty(); + assertThat(page.getTotalElements()).isEqualTo(3L); + } + + @Test + void _03_ShouldThrowBusinessRuleException_WhenPageIsNegative() { + assertThatThrownBy(() -> service().list(-1, 10)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(customerDao); + } + + @Test + void _04_ShouldThrowBusinessRuleException_WhenSizeIsZero() { + assertThatThrownBy(() -> service().list(0, 0)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(customerDao); + } + + @Test + void _05_ShouldThrowBusinessRuleException_WhenSizeIsAbove100() { + assertThatThrownBy(() -> service().list(0, 101)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(customerDao); + } + + @Test + void _06_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() { + when(customerDao.findPage(0, 1)).thenReturn(List.of()); + when(customerDao.findPage(0, 100)).thenReturn(List.of()); + when(customerDao.countAll()).thenReturn(0L); + + assertThat(service().list(0, 1).getSize()).isEqualTo(1); + assertThat(service().list(0, 100).getSize()).isEqualTo(100); + } + + // ---------------------------------------------------------------- get + + @Test + void _07_ShouldReturnDto_WhenCustomerExists() { + when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", "1", "b@s.io", "Rd"))); + + assertThat(service().get(2L)).isEqualTo(new CustomerDto(2L, "Bob", "Stone", "1", "b@s.io", "Rd")); + } + + @Test + void _08_ShouldThrowResourceNotFoundException_WhenCustomerDoesNotExist() { + when(customerDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().get(99L)) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Customer not found: 99"); + } + + // ---------------------------------------------------------------- create + + @Test + void _09_ShouldTrimEveryFieldAndReturnGeneratedId_WhenCreatingACustomer() { + when(customerDao.insert(any(Customer.class))).thenAnswer(call -> { + Customer customer = call.getArgument(0); + customer.setId(7L); + return customer; + }); + + CustomerDto dto = service().create(input(" Alice ", " Martin ", " 555 ", " alice@example.com ", " 1 Main ")); + + assertThat(dto).isEqualTo(new CustomerDto(7L, "Alice", "Martin", "555", "alice@example.com", "1 Main")); + assertThat(created().getId()).isEqualTo(7L); + } + + @Test + void _10_ShouldStoreNull_WhenOptionalFieldsAreBlankOrAbsent() { + when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0)); + + service().create(input("Alice", "Martin", " ", "", null)); + + Customer stored = created(); + assertThat(stored.getTelephone()).isNull(); + assertThat(stored.getEmail()).isNull(); + assertThat(stored.getAddress()).isNull(); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void _11_ShouldThrowBusinessRuleException_WhenFirstOrLastNameIsBlank(String blank) { + assertThatThrownBy(() -> service().create(input(blank, "Martin", null, null, null))) + .isInstanceOf(BusinessRuleException.class).hasMessage("First name must not be blank"); + assertThatThrownBy(() -> service().create(input("Alice", blank, null, null, null))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Last name must not be blank"); + assertThatThrownBy(() -> service().create(input(null, "Martin", null, null, null))) + .isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(customerDao); + } + + @Test + void _12_ShouldAcceptNamesOf100AndRefuse101_WhenCheckingTheNameLimit() { + when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0)); + + service().create(input("x".repeat(100), "y".repeat(100), null, null, null)); + + assertThatThrownBy(() -> service().create(input("x".repeat(101), "Martin", null, null, null))) + .isInstanceOf(BusinessRuleException.class).hasMessage("First name must be at most 100 characters"); + assertThatThrownBy(() -> service().create(input("Alice", "y".repeat(101), null, null, null))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Last name must be at most 100 characters"); + } + + @Test + void _13_ShouldAcceptTelephoneOf30AndRefuse31_WhenCheckingTheTelephoneLimit() { + when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0)); + + service().create(input("Alice", "Martin", "1".repeat(30), null, null)); + + assertThatThrownBy(() -> service().create(input("Alice", "Martin", "1".repeat(31), null, null))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Telephone must be at most 30 characters"); + } + + @Test + void _14_ShouldAcceptAddressOf255AndRefuse256_WhenCheckingTheAddressLimit() { + when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0)); + + service().create(input("Alice", "Martin", null, null, "a".repeat(255))); + + assertThatThrownBy(() -> service().create(input("Alice", "Martin", null, null, "a".repeat(256)))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Address must be at most 255 characters"); + } + + @ParameterizedTest + @ValueSource(strings = {"alice@example.com", "a.b+c@sub.example.org", "a@b.c"}) + void _15_ShouldAcceptEmail_WhenFormatIsValid(String email) { + when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0)); + + assertThat(service().create(withEmail(email)).getEmail()).isEqualTo(email); + } + + @ParameterizedTest + @ValueSource(strings = {"alice.example.com", "a@@example.com", "a@b@example.com", "alice@example", "alice@.com", + "alice@example.", "ali ce@example.com", "alice@exa mple.com", "@example.com"}) + void _16_ShouldThrowBusinessRuleException_WhenEmailFormatIsInvalid(String email) { + assertThatThrownBy(() -> service().create(withEmail(email))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Email is not valid"); + verify(customerDao, never()).insert(any()); + } + + @Test + void _17_ShouldThrowBusinessRuleException_WhenEmailIsLongerThan255() { + String email = "a".repeat(250) + "@b.com"; + + assertThatThrownBy(() -> service().create(withEmail(email))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Email must be at most 255 characters"); + } + + // ---------------------------------------------------------------- update + + @Test + void _18_ShouldUpdateAndReturnCleanedDto_WhenCustomerExists() { + when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null))); + when(customerDao.update(any(Customer.class))).thenReturn(1); + + CustomerDto dto = service().update(2L, input(" Robert ", "Stone", " ", "rob@example.com", null)); + + assertThat(dto).isEqualTo(new CustomerDto(2L, "Robert", "Stone", null, "rob@example.com", null)); + ArgumentCaptor sent = ArgumentCaptor.forClass(Customer.class); + verify(customerDao).update(sent.capture()); + assertThat(sent.getValue().getId()).isEqualTo(2L); + } + + @Test + void _19_ShouldThrowResourceNotFoundExceptionWithoutUpdating_WhenCustomerDoesNotExist() { + when(customerDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().update(99L, input("Alice", "Martin", null, null, null))) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Customer not found: 99"); + verify(customerDao, never()).update(any()); + } + + @Test + void _20_ShouldThrowResourceNotFoundException_WhenNoRowIsAffectedByTheUpdate() { + when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null))); + when(customerDao.update(any(Customer.class))).thenReturn(0); + + assertThatThrownBy(() -> service().update(2L, input("Bob", "Stone", null, null, null))) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Customer not found: 2"); + } + + @Test + void _21_ShouldThrowBusinessRuleExceptionWithoutUpdating_WhenUpdatedEmailIsInvalid() { + when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null))); + + assertThatThrownBy(() -> service().update(2L, withEmail("nope"))) + .isInstanceOf(BusinessRuleException.class); + verify(customerDao, never()).update(any()); + } + + // ---------------------------------------------------------------- delete + + @Test + void _22_ShouldDelete_WhenCustomerExistsAndIsNotReferenced() { + when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null))); + + service().delete(2L); + + verify(customerDao).delete(2L); + } + + @Test + void _23_ShouldThrowResourceNotFoundExceptionWithoutDeleting_WhenCustomerDoesNotExist() { + when(customerDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().delete(99L)).isInstanceOf(ResourceNotFoundException.class); + verify(customerDao, never()).delete(any()); + } + + @Test + void _24_ShouldThrowBusinessRuleException_WhenTheForeignKeyOfOrdersRefusesTheDelete() { + when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null))); + doThrow(new DataIntegrityViolationException("fk_orders_customer")).when(customerDao).delete(2L); + + assertThatThrownBy(() -> service().delete(2L)) + .isInstanceOf(BusinessRuleException.class).hasMessage("Customer is referenced by orders"); + } +} diff --git a/core/customer/src/test/resources/customer-dao-dataset.sql b/core/customer/src/test/resources/customer-dao-dataset.sql new file mode 100644 index 0000000..9d4d59a --- /dev/null +++ b/core/customer/src/test/resources/customer-dao-dataset.sql @@ -0,0 +1,19 @@ +-- Fixture of CustomerDaoImplTest only. Wipes the customer and order tables (and the catalog rows an order needs), +-- then loads four customers: Alice (1) and Carol (3) and Dan (4) with every column, Bob (2) with NULL telephone, +-- email and address. One category and one product exist so a test can insert an order by SQL. +TRUNCATE TABLE orders, products, categories, customers RESTART IDENTITY CASCADE; + +INSERT INTO customers (id, first_name, last_name, telephone, email, address) VALUES + (1, 'Alice', 'Martin', '+33 1 23 45', 'alice@example.com', '1 Main Street'), + (2, 'Bob', 'Stone', NULL, NULL, NULL), + (3, 'Carol', 'Diaz', '555-0303', 'carol@example.com', '3 Side Road'), + (4, 'Dan', 'Lee', '555-0404', 'dan@example.com', '4 Long Avenue'); + +INSERT INTO categories (id, category_name) VALUES (1, 'Books'); +INSERT INTO products (id, category_id, product_name, unit_price) VALUES (1, 1, 'Novel', 12.50); + +-- Explicit ids do not advance the sequences: move them high so generated ids never collide +ALTER SEQUENCE customers_id_seq RESTART WITH 100; +ALTER SEQUENCE categories_id_seq RESTART WITH 100; +ALTER SEQUENCE products_id_seq RESTART WITH 100; +ALTER SEQUENCE orders_id_seq RESTART WITH 100; diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java index e1185f7..0657ea5 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -38,7 +38,8 @@ @Configuration @EnableWebMvc @Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class, - com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class, SecurityConfig.class}) + com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class, + com.edgareldy.springjdbctutorial.core.customer.config.ServiceConfig.class, SecurityConfig.class}) @ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"}) public class WebMvcConfig implements WebMvcConfigurer { diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CustomerController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CustomerController.java new file mode 100644 index 0000000..3487790 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CustomerController.java @@ -0,0 +1,77 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService; +import com.edgareldy.springjdbctutorial.ws.converter.CustomerConverter; +import com.edgareldy.springjdbctutorial.ws.payload.customer.CustomerRequest; +import com.edgareldy.springjdbctutorial.ws.payload.customer.CustomerResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points to manage customers. No business logic. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@RestController +@RequestMapping("/api/v1/customers") +public class CustomerController { + + private final CustomerService customerService; + + public CustomerController(CustomerService customerService) { + this.customerService = customerService; + } + + @GetMapping + @PreAuthorize("hasPermission('CUSTOMER','READ')") + public ApiResponse> list(@RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ApiResponse.success(CustomerConverter.toPageResponse(customerService.list(page, size)), "Customers"); + } + + @GetMapping("/{id}") + @PreAuthorize("hasPermission('CUSTOMER','READ')") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.success(CustomerConverter.toResponse(customerService.get(id)), "Customer"); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasPermission('CUSTOMER','WRITE')") + public ApiResponse create(@Valid @RequestBody CustomerRequest request) { + return ApiResponse.success(CustomerConverter.toResponse(customerService.create(CustomerConverter.toDto(request))), + "Customer created"); + } + + @PutMapping("/{id}") + @PreAuthorize("hasPermission('CUSTOMER','WRITE')") + public ApiResponse update(@PathVariable Long id, @Valid @RequestBody CustomerRequest request) { + return ApiResponse.success(CustomerConverter.toResponse(customerService.update(id, CustomerConverter.toDto(request))), + "Customer updated"); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasPermission('CUSTOMER','WRITE')") + public ApiResponse delete(@PathVariable Long id) { + customerService.delete(id); + return ApiResponse.success(null, "Customer deleted"); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CustomerConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CustomerConverter.java new file mode 100644 index 0000000..fcd7fca --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CustomerConverter.java @@ -0,0 +1,47 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import com.edgareldy.springjdbctutorial.ws.payload.customer.CustomerRequest; +import com.edgareldy.springjdbctutorial.ws.payload.customer.CustomerResponse; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts between the core CustomerDto and the customer HTTP payloads. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public final class CustomerConverter { + + private CustomerConverter() { + } + + public static CustomerDto toDto(CustomerRequest request) { + CustomerDto dto = new CustomerDto(); + dto.setFirstName(request.getFirstName()); + dto.setLastName(request.getLastName()); + dto.setTelephone(request.getTelephone()); + dto.setEmail(request.getEmail()); + dto.setAddress(request.getAddress()); + return dto; + } + + public static CustomerResponse toResponse(CustomerDto dto) { + return new CustomerResponse(dto.getId(), dto.getFirstName(), dto.getLastName(), + dto.getTelephone(), dto.getEmail(), dto.getAddress()); + } + + public static PageResponse toPageResponse(PageDto page) { + List content = new ArrayList<>(); + for (CustomerDto dto : page.getContent()) { + content.add(toResponse(dto)); + } + return PageResponse.of(content, page.getPage(), page.getSize(), page.getTotalElements()); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerRequest.java new file mode 100644 index 0000000..2c648ad --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerRequest.java @@ -0,0 +1,110 @@ +package com.edgareldy.springjdbctutorial.ws.payload.customer; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.Objects; + +/** + * Body of the create and update customer calls. Optional fields pass when null or empty, the service turns blank ones into null. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerRequest { + + @NotBlank + @Size(max = 100) + private String firstName; + + @NotBlank + @Size(max = 100) + private String lastName; + + @Size(max = 30) + private String telephone; + + // @Email accepts null and the empty string, so an absent or empty email is not a validation error + @Email + @Size(max = 255) + private String email; + + @Size(max = 255) + private String address; + + public CustomerRequest() { + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getTelephone() { + return telephone; + } + + public void setTelephone(String telephone) { + this.telephone = telephone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CustomerRequest that = (CustomerRequest) o; + return Objects.equals(firstName, that.firstName) && Objects.equals(lastName, that.lastName) + && Objects.equals(telephone, that.telephone) && Objects.equals(email, that.email) + && Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(firstName, lastName, telephone, email, address); + } + + @Override + public String toString() { + return "CustomerRequest{" + + "firstName=" + firstName + + ", lastName=" + lastName + + ", telephone=" + telephone + + ", email=" + email + + ", address=" + address + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerResponse.java new file mode 100644 index 0000000..52192bb --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerResponse.java @@ -0,0 +1,117 @@ +package com.edgareldy.springjdbctutorial.ws.payload.customer; + +import java.util.Objects; + +/** + * A customer as returned by the API. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerResponse { + + private Long id; + + private String firstName; + + private String lastName; + + private String telephone; + + private String email; + + private String address; + + public CustomerResponse() { + } + + public CustomerResponse(Long id, String firstName, String lastName, String telephone, String email, String address) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.telephone = telephone; + this.email = email; + this.address = address; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getTelephone() { + return telephone; + } + + public void setTelephone(String telephone) { + this.telephone = telephone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CustomerResponse that = (CustomerResponse) o; + return Objects.equals(id, that.id) && Objects.equals(firstName, that.firstName) + && Objects.equals(lastName, that.lastName) && Objects.equals(telephone, that.telephone) + && Objects.equals(email, that.email) && Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, telephone, email, address); + } + + @Override + public String toString() { + return "CustomerResponse{" + + "id=" + id + + ", firstName=" + firstName + + ", lastName=" + lastName + + ", telephone=" + telephone + + ", email=" + email + + ", address=" + address + + "}"; + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CustomerFlowIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CustomerFlowIntegrationTest.java new file mode 100644 index 0000000..c25ddee --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CustomerFlowIntegrationTest.java @@ -0,0 +1,303 @@ +package com.edgareldy.springjdbctutorial.ws.integration; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport; +import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport.TestUser; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * End-to-end customer flows through the REAL stack (real WebMvcConfig, real service, real PostgreSQL migrated by + * Flyway, real logins): CRUD, optional fields, the email format rules at both levels, the delete refused while + * orders reference the customer, pagination bounds and permission enforcement. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as CatalogFlowIntegrationTest: the whole application context on the shared Testcontainers database. +// Every customer a test creates has a it- first name, and tearDown() removes them (with the orders, product +// and category some tests insert by SQL, and the users and roles of RbacTestSupport). +@SpringJUnitWebConfig(WebMvcConfig.class) +class CustomerFlowIntegrationTest { + + private static final String PREFIX = "it-"; + private static final String URL = "/api/v1/customers"; + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private WebApplicationContext context; + @Autowired + private JdbcTemplate jdbcTemplate; + + private MockMvc mockMvc; + private RbacTestSupport support; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + support = new RbacTestSupport(mockMvc, jdbcTemplate); + } + + @AfterEach + void tearDown() { + // Orders first: they reference the customers and the products + jdbcTemplate.update("DELETE FROM orders WHERE customer_id IN (SELECT id FROM customers WHERE first_name LIKE ?)", + PREFIX + "%"); + jdbcTemplate.update("DELETE FROM customers WHERE first_name LIKE ?", PREFIX + "%"); + jdbcTemplate.update("DELETE FROM products WHERE product_name LIKE ?", PREFIX + "%"); + jdbcTemplate.update("DELETE FROM categories WHERE category_name LIKE ?", PREFIX + "%"); + support.close(); + } + + private static String unique() { + return PREFIX + UUID.randomUUID().toString().substring(0, 8); + } + + private static String body(String firstName, String lastName, String telephone, String email, String address) { + return "{\"firstName\":\"" + firstName + "\",\"lastName\":\"" + lastName + "\",\"telephone\":" + quote(telephone) + + ",\"email\":" + quote(email) + ",\"address\":" + quote(address) + "}"; + } + + private static String quote(String value) { + return value == null ? "null" : "\"" + value + "\""; + } + + private static String minimalBody(String firstName) { + return "{\"firstName\":\"" + firstName + "\",\"lastName\":\"Martin\"}"; + } + + private static long idOf(MvcResult result) throws Exception { + return assertSuccess(result, 201).get("data").get("id").asLong(); + } + + private long count(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Long.class, args); + } + + @Test + void _01_ShouldCreateReadUpdateListAndDeleteACustomer_WhenAdminRunsTheFullCrud() throws Exception { + String jwt = support.createAdmin().getJwt(); + String name = unique(); + + MvcResult created = support.post(jwt, URL, body(" " + name + " ", " Martin ", " 555 ", "a@example.com", " 1 Main ")); + long id = idOf(created); + + // Every text is stored trimmed (the email is sent without spaces: @Email rejects surrounding spaces with a 400) + JsonNode read = assertSuccess(support.get(jwt, URL + "/" + id), 200).get("data"); + assertThat(read.get("firstName").asText()).isEqualTo(name); + assertThat(read.get("lastName").asText()).isEqualTo("Martin"); + assertThat(read.get("telephone").asText()).isEqualTo("555"); + assertThat(read.get("email").asText()).isEqualTo("a@example.com"); + assertThat(read.get("address").asText()).isEqualTo("1 Main"); + + String renamed = unique(); + JsonNode updated = assertSuccess(support.put(jwt, URL + "/" + id, + body(renamed, "Stone", "777", "b@example.com", "2 Road")), 200); + assertThat(updated.get("data").get("firstName").asText()).isEqualTo(renamed); + assertThat(count("SELECT COUNT(*) FROM customers WHERE first_name = ? AND last_name = 'Stone' AND telephone = '777'", + renamed)).isEqualTo(1L); + + JsonNode list = assertSuccess(support.get(jwt, URL + "?size=100"), 200); + boolean listed = false; + for (JsonNode customer : list.get("data").get("content")) { + listed |= customer.get("id").asLong() == id; + } + assertThat(listed).isTrue(); + + JsonNode deleted = assertSuccess(support.delete(jwt, URL + "/" + id), 200); + assertThat(deleted.get("data") == null || deleted.get("data").isNull()).isTrue(); + assertError(support.get(jwt, URL + "/" + id), 404); + } + + @Test + void _02_ShouldStoreNullOptionalFields_WhenTheyAreAbsentEmptyOrBlank() throws Exception { + String jwt = support.createAdmin().getJwt(); + + long absent = idOf(support.post(jwt, URL, minimalBody(unique()))); + long blank = idOf(support.post(jwt, URL, body(unique(), "Martin", " ", "", " "))); + + for (long id : List.of(absent, blank)) { + JsonNode data = assertSuccess(support.get(jwt, URL + "/" + id), 200).get("data"); + assertThat(data.get("telephone").isNull()).isTrue(); + assertThat(data.get("email").isNull()).isTrue(); + assertThat(data.get("address").isNull()).isTrue(); + assertThat(count("SELECT COUNT(*) FROM customers WHERE id = ? AND telephone IS NULL AND email IS NULL " + + "AND address IS NULL", id)).isEqualTo(1L); + } + } + + @Test + void _03_ShouldClearOptionalFields_WhenUpdatedWithoutThem() throws Exception { + String jwt = support.createAdmin().getJwt(); + String name = unique(); + long id = idOf(support.post(jwt, URL, body(name, "Martin", "555", "a@example.com", "1 Main"))); + + assertSuccess(support.put(jwt, URL + "/" + id, minimalBody(name)), 200); + + assertThat(count("SELECT COUNT(*) FROM customers WHERE id = ? AND telephone IS NULL AND email IS NULL " + + "AND address IS NULL", id)).isEqualTo(1L); + } + + @Test + void _04_ShouldReturn400OrHandOverTo422_WhenEmailIsMalformed() throws Exception { + String jwt = support.createAdmin().getJwt(); + String name = unique(); + + // Refused by Bean Validation (@Email) before the service runs: 400 naming the field + for (String bad : List.of("no-at-sign", "a@@b.com", "a b@c.com", "@c.com")) { + JsonNode json = assertError(support.post(jwt, URL, body(name, "Martin", null, bad, null)), 400); + assertThat(json.get("message").asText()).contains("email: "); + } + // The Bean Validation rule accepts a domain without a dot, the service rule then refuses it: 422 + JsonNode noDot = assertError(support.post(jwt, URL, body(name, "Martin", null, "alice@example", null)), 422); + assertThat(noDot.get("message").asText()).isEqualTo("Email is not valid"); + JsonNode onUpdate = assertError(support.put(jwt, URL + "/" + idOf(support.post(jwt, URL, minimalBody(name))), + body(name, "Martin", null, "alice@example", null)), 422); + assertThat(onUpdate.get("message").asText()).isEqualTo("Email is not valid"); + assertThat(count("SELECT COUNT(*) FROM customers WHERE first_name = ? AND email IS NOT NULL", name)).isZero(); + } + + @Test + void _05_ShouldReturn400NamingTheFieldAndStoreNothing_WhenBodyIsInvalid() throws Exception { + String jwt = support.createAdmin().getJwt(); + String name = unique(); + + JsonNode blank = assertError(support.post(jwt, URL, body(" ", "Martin", null, null, null)), 400); + JsonNode oversize = assertError(support.post(jwt, URL, body(name, "y".repeat(101), null, null, null)), 400); + JsonNode phone = assertError(support.post(jwt, URL, body(name, "Martin", "1".repeat(31), null, null)), 400); + + assertThat(blank.get("message").asText()).contains("firstName: "); + assertThat(oversize.get("message").asText()).contains("lastName: "); + assertThat(phone.get("message").asText()).contains("telephone: "); + assertThat(count("SELECT COUNT(*) FROM customers WHERE first_name = ?", name)).isZero(); + } + + @Test + void _06_ShouldReturn404_WhenCustomerDoesNotExist() throws Exception { + String jwt = support.createAdmin().getJwt(); + + assertError(support.get(jwt, URL + "/999999999"), 404); + assertError(support.put(jwt, URL + "/999999999", minimalBody(unique())), 404); + assertError(support.delete(jwt, URL + "/999999999"), 404); + } + + @Test + void _07_ShouldRefuseTheDeleteWith422AndKeepTheCustomer_WhenAnOrderReferencesIt() throws Exception { + String jwt = support.createAdmin().getJwt(); + long id = idOf(support.post(jwt, URL, minimalBody(unique()))); + // The order endpoints do not exist yet: a category, a product and the order are inserted through SQL + long categoryId = jdbcTemplate.queryForObject( + "INSERT INTO categories (category_name) VALUES (?) RETURNING id", Long.class, unique()); + long productId = jdbcTemplate.queryForObject( + "INSERT INTO products (category_id, product_name, unit_price) VALUES (?, ?, 5.00) RETURNING id", + Long.class, categoryId, unique()); + jdbcTemplate.update("INSERT INTO orders (customer_id, product_id, quantity, total) VALUES (?, ?, 2, 10.00)", + id, productId); + + JsonNode refused = assertError(support.delete(jwt, URL + "/" + id), 422); + + assertThat(refused.get("message").asText()).isEqualTo("Customer is referenced by orders"); + assertSuccess(support.get(jwt, URL + "/" + id), 200); + assertThat(count("SELECT COUNT(*) FROM customers WHERE id = ?", id)).isEqualTo(1L); + + // Once the order is gone the same delete goes through + jdbcTemplate.update("DELETE FROM orders WHERE customer_id = ?", id); + assertSuccess(support.delete(jwt, URL + "/" + id), 200); + assertError(support.get(jwt, URL + "/" + id), 404); + } + + @Test + void _08_ShouldReturn400NamingTheParameter_WhenPageOrSizeIsOutOfBounds() throws Exception { + String jwt = support.createAdmin().getJwt(); + + JsonNode page = assertError(support.get(jwt, URL + "?page=-1"), 400); + JsonNode zero = assertError(support.get(jwt, URL + "?size=0"), 400); + JsonNode big = assertError(support.get(jwt, URL + "?size=101"), 400); + + assertThat(page.get("message").asText()).contains("page: "); + assertThat(zero.get("message").asText()).contains("size: "); + assertThat(big.get("message").asText()).contains("size: "); + assertSuccess(support.get(jwt, URL + "?size=1"), 200); + assertSuccess(support.get(jwt, URL + "?size=100"), 200); + } + + @Test + void _09_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + String jwt = support.createAdmin().getJwt(); + idOf(support.post(jwt, URL, minimalBody(unique()))); + + JsonNode data = assertSuccess(support.get(jwt, URL + "?page=100000&size=10"), 200).get("data"); + + assertThat(data.has("content")).isTrue(); + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("page").asInt()).isEqualTo(100000); + assertThat(data.get("totalElements").asLong()).isGreaterThanOrEqualTo(1L); + } + + @Test + void _10_ShouldReturn401_WhenNoTokenIsSent() throws Exception { + for (String url : List.of(URL, URL + "/1")) { + assertError(support.get(null, url), 401); + } + assertError(support.post(null, URL, minimalBody(unique())), 401); + assertError(support.put(null, URL + "/1", minimalBody(unique())), 401); + assertError(support.delete(null, URL + "/1"), 401); + } + + @Test + void _11_ShouldReturn403AndChangeNothing_WhenUserLacksTheWritePermission() throws Exception { + TestUser simple = support.createSimpleUser(); + TestUser readOnly = support.createUser(true, false, support.createRole("CUSTOMER:READ")); + String admin = support.createAdmin().getJwt(); + String existing = unique(); + long id = idOf(support.post(admin, URL, minimalBody(existing))); + String name = unique(); + + for (TestUser user : List.of(simple, readOnly)) { + assertError(support.post(user.getJwt(), URL, minimalBody(name)), 403); + assertError(support.put(user.getJwt(), URL + "/" + id, minimalBody(name)), 403); + assertError(support.delete(user.getJwt(), URL + "/" + id), 403); + } + assertThat(count("SELECT COUNT(*) FROM customers WHERE first_name = ?", name)).isZero(); + assertThat(count("SELECT COUNT(*) FROM customers WHERE id = ? AND first_name = ?", id, existing)).isEqualTo(1L); + // The simple user has no read permission either, the read-only user does + assertError(support.get(simple.getJwt(), URL), 403); + assertSuccess(support.get(readOnly.getJwt(), URL + "/" + id), 200); + } + + @Test + void _12_ShouldAllowOnlyWhatEachPermissionGrants_WhenUsersHoldASingleCustomerPermission() throws Exception { + TestUser writer = support.createUser(true, false, support.createRole("CUSTOMER:WRITE")); + + // CUSTOMER:WRITE alone creates customers but neither lists nor reads them + long id = idOf(support.post(writer.getJwt(), URL, minimalBody(unique()))); + assertError(support.get(writer.getJwt(), URL + "/" + id), 403); + assertError(support.get(writer.getJwt(), URL), 403); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCustomerMvcTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCustomerMvcTest.java new file mode 100644 index 0000000..310f294 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCustomerMvcTest.java @@ -0,0 +1,92 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static org.mockito.Mockito.reset; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService; +import com.edgareldy.springjdbctutorial.ws.support.JwtTestSupport; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * Common base of the customer controller tests: builds MockMvc on the shared CustomerMvcTestConfig context, resets + * the mocked services before each test and offers request helpers (signed tokens, JSON bodies). + *

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

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CustomerControllerTest extends AbstractCustomerMvcTest { + + private static final String READ = "CUSTOMER:READ"; + private static final String WRITE = "CUSTOMER:WRITE"; + private static final String URL = "/api/v1/customers"; + private static final String VALID_BODY = "{\"firstName\":\"Alice\",\"lastName\":\"Martin\"," + + "\"telephone\":\"555\",\"email\":\"alice@example.com\",\"address\":\"1 Main Street\"}"; + private static final CustomerDto ALICE = new CustomerDto(1L, "Alice", "Martin", "555", "alice@example.com", "1 Main Street"); + + private static String body(String firstName, String lastName, String email) { + return "{\"firstName\":\"" + firstName + "\",\"lastName\":\"" + lastName + "\",\"email\":\"" + email + "\"}"; + } + + // ---------------------------------------------------------------- GET /customers + + @Test + void _01_ShouldReturn200WithPageAndDefaults_WhenTokenHoldsCustomerRead() throws Exception { + when(customerService.list(0, 20)).thenReturn(new PageDto<>(List.of(ALICE), 0, 20, 1)); + + JsonNode data = assertSuccess(call(getWith(URL, token(READ))), 200).get("data"); + + JsonNode first = data.get("content").get(0); + assertThat(first.get("id").asLong()).isEqualTo(1L); + assertThat(first.get("firstName").asText()).isEqualTo("Alice"); + assertThat(first.get("lastName").asText()).isEqualTo("Martin"); + assertThat(first.get("telephone").asText()).isEqualTo("555"); + assertThat(first.get("email").asText()).isEqualTo("alice@example.com"); + assertThat(first.get("address").asText()).isEqualTo("1 Main Street"); + assertThat(data.get("page").asInt()).isZero(); + assertThat(data.get("size").asInt()).isEqualTo(20); + assertThat(data.get("totalElements").asLong()).isEqualTo(1L); + assertThat(data.get("totalPages").asInt()).isEqualTo(1); + } + + @Test + void _02_ShouldPassPageAndSizeToTheService_WhenTheyAreGiven() throws Exception { + when(customerService.list(2, 5)).thenReturn(new PageDto<>(List.of(), 2, 5, 11)); + + JsonNode json = assertSuccess(call(getWith(URL + "?page=2&size=5", token(READ))), 200); + + assertThat(json.get("data").get("page").asInt()).isEqualTo(2); + assertThat(json.get("data").get("size").asInt()).isEqualTo(5); + verify(customerService).list(2, 5); + } + + @Test + void _03_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + when(customerService.list(99, 10)).thenReturn(new PageDto<>(List.of(), 99, 10, 3)); + + JsonNode data = assertSuccess(call(getWith(URL + "?page=99&size=10", token(READ))), 200).get("data"); + + assertThat(data.has("content")).isTrue(); + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("totalElements").asLong()).isEqualTo(3L); + } + + @Test + void _04_ShouldReturn400NamingPage_WhenPageIsNegative() throws Exception { + JsonNode json = assertError(call(getWith(URL + "?page=-1", token(READ))), 400); + + assertThat(json.get("message").asText()).contains("page: ").doesNotContain("size: "); + verifyNoInteractions(customerService); + } + + @Test + void _05_ShouldReturn400NamingSize_WhenSizeIsZeroOrAbove100() throws Exception { + JsonNode zero = assertError(call(getWith(URL + "?size=0", token(READ))), 400); + JsonNode big = assertError(call(getWith(URL + "?size=101", token(READ))), 400); + + assertThat(zero.get("message").asText()).contains("size: ").doesNotContain("page: "); + assertThat(big.get("message").asText()).contains("size: "); + verifyNoInteractions(customerService); + } + + @Test + void _06_ShouldReturn400NamingBoth_WhenPageAndSizeAreInvalid() throws Exception { + JsonNode json = assertError(call(getWith(URL + "?page=-5&size=500", token(READ))), 400); + + assertThat(json.get("message").asText()).contains("page: ").contains("size: "); + } + + @Test + void _07_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() throws Exception { + when(customerService.list(0, 1)).thenReturn(new PageDto<>(List.of(), 0, 1, 0)); + when(customerService.list(0, 100)).thenReturn(new PageDto<>(List.of(), 0, 100, 0)); + + assertSuccess(call(getWith(URL + "?size=1", token(READ))), 200); + assertSuccess(call(getWith(URL + "?size=100", token(READ))), 200); + } + + // ---------------------------------------------------------------- GET /customers/{id} + + @Test + void _08_ShouldReturn200WithNullOptionalFields_WhenCustomerHasNoOptionalData() throws Exception { + when(customerService.get(2L)).thenReturn(new CustomerDto(2L, "Bob", "Stone", null, null, null)); + + JsonNode json = assertSuccess(call(getWith(URL + "/2", token(READ))), 200); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(2L); + assertThat(json.get("data").get("firstName").asText()).isEqualTo("Bob"); + assertThat(json.get("data").get("telephone").isNull()).isTrue(); + assertThat(json.get("data").get("email").isNull()).isTrue(); + assertThat(json.get("data").get("address").isNull()).isTrue(); + } + + @Test + void _09_ShouldReturn404ApiResponse_WhenCustomerDoesNotExist() throws Exception { + when(customerService.get(99L)).thenThrow(new ResourceNotFoundException("Customer not found: 99")); + + JsonNode json = assertError(call(getWith(URL + "/99", token(READ))), 404); + + assertThat(json.get("message").asText()).isEqualTo("Customer not found: 99"); + } + + @Test + void _10_ShouldReturn400_WhenCustomerIdIsNotANumber() throws Exception { + assertError(call(getWith(URL + "/abc", token(READ))), 400); + verifyNoInteractions(customerService); + } + + // ---------------------------------------------------------------- POST /customers + + @Test + void _11_ShouldReturn201WithCreatedCustomer_WhenBodyIsValid() throws Exception { + when(customerService.create(any(CustomerDto.class))).thenReturn(ALICE); + + JsonNode json = assertSuccess(call(postJson(URL, token(WRITE), VALID_BODY)), 201); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(1L); + assertThat(json.get("message").asText()).isEqualTo("Customer created"); + ArgumentCaptor sent = ArgumentCaptor.forClass(CustomerDto.class); + verify(customerService).create(sent.capture()); + assertThat(sent.getValue()).isEqualTo(new CustomerDto(null, "Alice", "Martin", "555", "alice@example.com", "1 Main Street")); + } + + @Test + void _12_ShouldReturn201_WhenOptionalFieldsAreAbsentOrEmpty() throws Exception { + when(customerService.create(any(CustomerDto.class))).thenReturn(new CustomerDto(3L, "Bob", "Stone", null, null, null)); + + assertSuccess(call(postJson(URL, token(WRITE), "{\"firstName\":\"Bob\",\"lastName\":\"Stone\"}")), 201); + assertSuccess(call(postJson(URL, token(WRITE), + "{\"firstName\":\"Bob\",\"lastName\":\"Stone\",\"telephone\":\"\",\"email\":\"\",\"address\":\"\"}")), 201); + } + + @Test + void _13_ShouldReturn400NamingTheField_WhenFirstOrLastNameIsBlankOrMissing() throws Exception { + JsonNode first = assertError(call(postJson(URL, token(WRITE), body(" ", "Martin", "a@b.co"))), 400); + JsonNode last = assertError(call(postJson(URL, token(WRITE), body("Alice", "", "a@b.co"))), 400); + JsonNode missing = assertError(call(postJson(URL, token(WRITE), "{}")), 400); + + assertThat(first.get("message").asText()).contains("firstName: ").doesNotContain("lastName: "); + assertThat(last.get("message").asText()).contains("lastName: ").doesNotContain("firstName: "); + assertThat(missing.get("message").asText()).contains("firstName: ").contains("lastName: "); + verifyNoInteractions(customerService); + } + + @Test + void _14_ShouldReturn400NamingTheField_WhenEmailIsMalformed() throws Exception { + JsonNode json = assertError(call(postJson(URL, token(WRITE), body("Alice", "Martin", "not-an-email"))), 400); + + assertThat(json.get("message").asText()).contains("email: "); + verifyNoInteractions(customerService); + } + + @Test + void _15_ShouldReturn400NamingTheField_WhenAFieldIsOversize() throws Exception { + String longName = "x".repeat(101); + JsonNode first = assertError(call(postJson(URL, token(WRITE), body(longName, "Martin", ""))), 400); + JsonNode last = assertError(call(postJson(URL, token(WRITE), body("Alice", longName, ""))), 400); + JsonNode phone = assertError(call(postJson(URL, token(WRITE), + "{\"firstName\":\"A\",\"lastName\":\"B\",\"telephone\":\"" + "1".repeat(31) + "\"}")), 400); + JsonNode address = assertError(call(postJson(URL, token(WRITE), + "{\"firstName\":\"A\",\"lastName\":\"B\",\"address\":\"" + "a".repeat(256) + "\"}")), 400); + JsonNode email = assertError(call(postJson(URL, token(WRITE), + body("Alice", "Martin", "a".repeat(250) + "@b.com"))), 400); + + assertThat(first.get("message").asText()).contains("firstName: "); + assertThat(last.get("message").asText()).contains("lastName: "); + assertThat(phone.get("message").asText()).contains("telephone: "); + assertThat(address.get("message").asText()).contains("address: "); + assertThat(email.get("message").asText()).contains("email: "); + verifyNoInteractions(customerService); + } + + @Test + void _16_ShouldReturn201_WhenFieldsSitExactlyOnTheirLimits() throws Exception { + when(customerService.create(any(CustomerDto.class))).thenReturn(ALICE); + String limits = "{\"firstName\":\"" + "x".repeat(100) + "\",\"lastName\":\"" + "y".repeat(100) + + "\",\"telephone\":\"" + "1".repeat(30) + "\",\"address\":\"" + "a".repeat(255) + "\"}"; + + assertSuccess(call(postJson(URL, token(WRITE), limits)), 201); + } + + @Test + void _17_ShouldReturn400_WhenBodyIsMalformedJson() throws Exception { + assertError(call(postJson(URL, token(WRITE), "{\"firstName\":")), 400); + verifyNoInteractions(customerService); + } + + @Test + void _18_ShouldReturn422_WhenServiceRefusesTheEmailFormat() throws Exception { + when(customerService.create(any(CustomerDto.class))).thenThrow(new BusinessRuleException("Email is not valid")); + + JsonNode json = assertError(call(postJson(URL, token(WRITE), body("Alice", "Martin", "a@b"))), 422); + + assertThat(json.get("message").asText()).isEqualTo("Email is not valid"); + } + + @Test + void _19_ShouldReturn415_WhenContentTypeIsNotJson() throws Exception { + assertError(call(MockMvcRequestBuilders.post(URL) + .header("Authorization", bearer(token(WRITE))).contentType(MediaType.TEXT_PLAIN).content("Alice")), 415); + verifyNoInteractions(customerService); + } + + // ---------------------------------------------------------------- PUT /customers/{id} + + @Test + void _20_ShouldReturn200WithUpdatedCustomer_WhenBodyIsValid() throws Exception { + when(customerService.update(eq(1L), any(CustomerDto.class))).thenReturn(ALICE); + + JsonNode json = assertSuccess(call(putJson(URL + "/1", token(WRITE), VALID_BODY)), 200); + + assertThat(json.get("data").get("firstName").asText()).isEqualTo("Alice"); + assertThat(json.get("message").asText()).isEqualTo("Customer updated"); + } + + @Test + void _21_ShouldReturn404ApiResponse_WhenUpdatingAnUnknownCustomer() throws Exception { + when(customerService.update(eq(99L), any(CustomerDto.class))) + .thenThrow(new ResourceNotFoundException("Customer not found: 99")); + + assertError(call(putJson(URL + "/99", token(WRITE), VALID_BODY)), 404); + } + + @Test + void _22_ShouldReturn400_WhenUpdatedBodyIsInvalid() throws Exception { + JsonNode json = assertError(call(putJson(URL + "/1", token(WRITE), body("", "Martin", "bad"))), 400); + + assertThat(json.get("message").asText()).contains("firstName: ").contains("email: "); + verifyNoInteractions(customerService); + } + + // ---------------------------------------------------------------- DELETE /customers/{id} + + @Test + void _23_ShouldReturn200WithoutData_WhenCustomerIsDeleted() throws Exception { + JsonNode json = assertSuccess(call(deleteWith(URL + "/2", token(WRITE))), 200); + + assertThat(json.get("message").asText()).isEqualTo("Customer deleted"); + assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); + verify(customerService).delete(2L); + } + + @Test + void _24_ShouldReturn404ApiResponse_WhenDeletingAnUnknownCustomer() throws Exception { + doThrow(new ResourceNotFoundException("Customer not found: 99")).when(customerService).delete(99L); + + assertError(call(deleteWith(URL + "/99", token(WRITE))), 404); + } + + @Test + void _25_ShouldReturn422_WhenCustomerIsReferencedByOrders() throws Exception { + doThrow(new BusinessRuleException("Customer is referenced by orders")).when(customerService).delete(2L); + + JsonNode json = assertError(call(deleteWith(URL + "/2", token(WRITE))), 422); + + assertThat(json.get("message").asText()).isEqualTo("Customer is referenced by orders"); + } + + // ---------------------------------------------------------------- unsupported method + + @Test + void _26_ShouldReturn405ApiResponse_WhenPatchIsUsedOnACustomer() throws Exception { + assertError(call(patchWith(URL + "/2", token(WRITE))), 405); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerEndpointSecurityTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerEndpointSecurityTest.java new file mode 100644 index 0000000..c041293 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerEndpointSecurityTest.java @@ -0,0 +1,117 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +/** + * Permission matrix of the 5 customer endpoints of the README table, checked through the real filter chain and + * method security: no token gives 401, a revoked token gives 401, a token holding every permission EXCEPT the + * required one (or none at all) gives 403, all as ApiResponse errors and without ever reaching the (mocked) + * service; the right permission alone gives 2xx. + *

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

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