add addresses & more tests

This commit is contained in:
Peter Straßer
2021-04-12 20:52:00 +02:00
parent 8593683c0e
commit d2543621e1
17 changed files with 406 additions and 127 deletions

View File

@@ -18,10 +18,11 @@ To run this application you need to first run a
mvn clean install
```
in the root directory of this project. This will download all required dependecies and bundle your project properly.
in the root directory of this project. This will download all required dependecies and bundle your
project properly.
The database for this application is a mongo db. You either need to have one running on your system, or you can
alternatively start the MongoDB in docker with the included docker-compose file.
The database for this application is a mongo db. You either need to have one running on your system,
or you can alternatively start the MongoDB in docker with the included docker-compose file.
```cmd
docker-compose -f mongodb-docker-compose up
@@ -37,11 +38,13 @@ hexagonal-example\config\src\main\java\de\strasser\peter\hexagonal\HexagonalAppl
### What does this application do
This app can register customers, add addresses to these customers and retrieve a list of all customers.
This app can register customers, add addresses to these customers and retrieve a list of all
customers.
The supported usecases in the business layer can be inspected in the applicationmodule. Under
application/src/main/java/de/strasser/peter/hexagonal/application/customer/port all the supported operations are clearly
visible, which is a major selling point to structure your application in this way.
application/src/main/java/de/strasser/peter/hexagonal/application/customer/port all the supported
operations are clearly visible, which is a major selling point to structure your application in this
way.
![img_1.png](documentation/ports.png)
@@ -49,3 +52,13 @@ visible, which is a major selling point to structure your application in this wa
![img_3.jpg](documentation/dependency_diagram.jpg)
### Advantages of separated models on each layer in this example
- The customer response can easily exclude the (hashed) password without much effort. This seperates
the concern in what way to display the data to the client.
- The customer can be persisted different from the domain models structure. The layout in this
example has no benefit, but assume structuring your data in this way would give you a much needed
performance boost. This way the concern on how to handle data persistence is independent from the
business layer and can be handled by the persistence module.

View File

@@ -1,10 +0,0 @@
package de.strasser.peter.hexagonal.addressvalidation;
import de.strasser.peter.hexagonal.application.customer.port.out.commands.ValidateAddressCommand;
class AddressDoesNotExistExc extends IllegalArgumentException {
AddressDoesNotExistExc(ValidateAddressCommand validateAddressCommand) {
super(String.format("Address %s does not exist", validateAddressCommand.toString()));
}
}

View File

@@ -11,11 +11,11 @@ import org.springframework.stereotype.Component;
class AddressValidator implements AddressValidatorAdapter {
@Override
public Address validate(ValidateAddressCommand validateAddressCommand) {
public Address validate(ValidateAddressCommand validateAddressCommand) throws InvalidAddressExc {
// This could be some call to a 3rd party to validate this address.
if (validateAddressCommand.getStreet().equalsIgnoreCase("parkring")) {
log.info("Address is made up.");
throw new AddressDoesNotExistExc(validateAddressCommand);
throw new InvalidAddressExc(validateAddressCommand);
}
return new Address(

View File

@@ -0,0 +1,16 @@
package de.strasser.peter.hexagonal.addressvalidation;
import de.strasser.peter.hexagonal.application.customer.exception.BusinessException;
import de.strasser.peter.hexagonal.application.customer.port.out.commands.ValidateAddressCommand;
public class InvalidAddressExc extends BusinessException {
public InvalidAddressExc(ValidateAddressCommand validateAddressCommand) {
super(
String.format(
"Address <%s, %d, %d, %s> does not exist",
validateAddressCommand.getStreet(),
validateAddressCommand.getHouseNumber(),
validateAddressCommand.getZipCode(),
validateAddressCommand.getCountry()));
}
}

View File

@@ -4,6 +4,7 @@ import de.strasser.peter.hexagonal.application.customer.domain.Customer;
import de.strasser.peter.hexagonal.application.customer.port.in.QueryAllCustomersCRUD;
import de.strasser.peter.hexagonal.application.customer.port.out.LoadCustomerAdapter;
import de.strasser.peter.hexagonal.application.customer.port.out.SaveCustomerAdapter;
import de.strasser.peter.hexagonal.persistence.errors.CustomerDoesNotExistExc;
import de.strasser.peter.hexagonal.persistence.mapper.CustomerMapper;
import de.strasser.peter.hexagonal.persistence.model.CustomerEntity;
import de.strasser.peter.hexagonal.persistence.repository.CustomerRepository;
@@ -12,37 +13,31 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import java.math.BigInteger;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
@Slf4j
@Repository
@RequiredArgsConstructor
public class CustomerDao implements SaveCustomerAdapter, LoadCustomerAdapter, QueryAllCustomersCRUD {
private final CustomerRepository customerRepository;
private final CustomerMapper customerMapper;
public class CustomerDao
implements SaveCustomerAdapter, LoadCustomerAdapter, QueryAllCustomersCRUD {
private final CustomerRepository customerRepository;
private final CustomerMapper customerMapper;
@Override
public void upsert(Customer customer) {
log.info("saving customer");
customerRepository.save(customerMapper.toDbEntity(customer));
}
@Override
public void upsert(Customer customer) {
customerRepository.save(customerMapper.toDbEntity(customer));
}
@Override
public Customer findById(BigInteger id) {
Optional<CustomerEntity> byId = customerRepository.findById(id);
return Customer.createCustomer(id,
"max",
"mustermann",
LocalDate.of(1980, 1, 1),
new HashMap<>(),
true);
}
@Override
public Customer findById(BigInteger id) {
final CustomerEntity customerEntity =
customerRepository.findById(id).orElseThrow(() -> new CustomerDoesNotExistExc(id));
@Override
public List<Customer> getAll() {
return customerMapper.toDomain(customerRepository.findAll());
}
return customerMapper.toDomain(customerEntity);
}
@Override
public List<Customer> getAll() {
return customerMapper.toDomain(customerRepository.findAll());
}
}

View File

@@ -0,0 +1,11 @@
package de.strasser.peter.hexagonal.persistence.errors;
import de.strasser.peter.hexagonal.application.customer.exception.BusinessException;
import java.math.BigInteger;
public class CustomerDoesNotExistExc extends BusinessException {
public CustomerDoesNotExistExc(BigInteger id) {
super("Customer with id " + id.toString() + " does not exist!");
}
}

View File

@@ -2,13 +2,10 @@ package de.strasser.peter.hexagonal.web;
import de.strasser.peter.hexagonal.application.customer.port.in.AddAddressUseCase;
import de.strasser.peter.hexagonal.application.customer.port.in.commands.AddAddressCommand;
import de.strasser.peter.hexagonal.web.mapper.AddAddressWebMapper;
import de.strasser.peter.hexagonal.web.dto.request.AddAddressRequest;
import de.strasser.peter.hexagonal.web.mapper.AddAddressWebMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import java.math.BigInteger;
import java.util.List;
@@ -16,12 +13,14 @@ import java.util.List;
@RestController
@RequiredArgsConstructor
public class AddAddressController {
private final AddAddressUseCase addAddressUseCase;
private final AddAddressWebMapper addAddressMapper;
private final AddAddressUseCase addAddressUseCase;
private final AddAddressWebMapper addAddressMapper;
@PostMapping("/v1/address")
public void addAddress(@RequestParam BigInteger customerId, @RequestBody AddAddressRequest addAddressRequest) {
final List<AddAddressCommand> addAddressCmds = List.of(addAddressMapper.toCmd(addAddressRequest));
addAddressUseCase.addAddresses(customerId, addAddressCmds);
}
@PostMapping("/v1/customer/address")
public void addAddress(
@RequestParam BigInteger customerId, @RequestBody AddAddressRequest addAddressRequest) {
final List<AddAddressCommand> addAddressCmds =
List.of(addAddressMapper.toCmd(addAddressRequest));
addAddressUseCase.addAddresses(customerId, addAddressCmds);
}
}

View File

@@ -0,0 +1,15 @@
package de.strasser.peter.hexagonal.web.dto.response;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AddressResponse {
private String street;
private Integer houseNumber;
private Integer zipCode;
private String country;
}

View File

@@ -1,11 +1,13 @@
package de.strasser.peter.hexagonal.web.dto.response;
import de.strasser.peter.hexagonal.application.customer.domain.Address;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigInteger;
import java.time.LocalDate;
import java.util.Map;
@Data
@NoArgsConstructor
@@ -13,7 +15,7 @@ import java.time.LocalDate;
public class CustomerResponse {
private BigInteger id;
private String name;
private String hashedPassword;
private LocalDate birthday;
private Map<Address.AddressType, AddressResponse> addresses;
private int age;
}

View File

@@ -0,0 +1,51 @@
package de.strasser.peter.hexagonal.web;
import de.strasser.peter.hexagonal.application.customer.port.in.AddAddressUseCase;
import de.strasser.peter.hexagonal.application.customer.port.in.commands.AddAddressCommand;
import de.strasser.peter.hexagonal.common.validators.TestUtils;
import de.strasser.peter.hexagonal.web.mapper.AddAddressWebMapperImpl;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigInteger;
import java.util.List;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.then;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Slf4j
@WebMvcTest(controllers = AddAddressController.class)
@Import(AddAddressWebMapperImpl.class)
class AddAddressControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private AddAddressUseCase addAddressUseCase;
@Test
public void should_AddAddress() throws Exception {
final String body = TestUtils.readStringFromResource("valid_add_address.json");
final int customerId = 1231231;
mockMvc
.perform(
post("/v1/customer/address?customerId=" + customerId)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andReturn();
var addAddressCmd = new AddAddressCommand("default", "street", 59, 85748, "Germany");
then(addAddressUseCase)
.should()
.addAddresses(eq(BigInteger.valueOf(customerId)), eq(List.of(addAddressCmd)));
}
}

View File

@@ -0,0 +1,7 @@
{
"type": "default",
"street": "street",
"houseNumber": 59,
"zipCode": 85748,
"country": "Germany"
}

View File

@@ -10,7 +10,6 @@ public class Address {
Integer zipCode;
String country;
public enum AddressType {
DEFAULT,
SHIPPING,

View File

@@ -1,8 +1,10 @@
package de.strasser.peter.hexagonal.application.customer.domain;
import de.strasser.peter.hexagonal.application.customer.exception.DefaultAdressRequiredToActivateExc;
import de.strasser.peter.hexagonal.application.customer.exception.UserIsTooYoungExc;
import de.strasser.peter.hexagonal.application.customer.exception.TooOldToDeactivateExc;
import de.strasser.peter.hexagonal.application.customer.exception.TooYoungExc;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import java.math.BigInteger;
import java.time.LocalDate;
@@ -11,67 +13,63 @@ import java.util.HashMap;
import java.util.Map;
@Getter
@EqualsAndHashCode
@ToString
public class Customer {
private final BigInteger id;
private String name;
private String hashedPassword;
private LocalDate birthday;
private int age;
private Map<Address.AddressType, Address> addresses;
private boolean active;
private final BigInteger id;
private String name;
private String hashedPassword;
private LocalDate birthday;
private int age;
private Map<Address.AddressType, Address> addresses;
private boolean active;
private Customer(BigInteger id, String name, String hashedPassword, LocalDate birthDate, Map<Address.AddressType, Address> addresses, boolean active) {
this.id = id;
this.active = active;
this.age = Period.between(birthDate, LocalDate.now()).getYears();
this.birthday = birthDate;
this.name = name;
this.hashedPassword = hashedPassword;
this.addresses = addresses == null ? addresses : new HashMap<>();
if (this.age < 18) {
throw new UserIsTooYoungExc(age);
}
private Customer(
BigInteger id,
String name,
String hashedPassword,
LocalDate birthDate,
Map<Address.AddressType, Address> addresses,
boolean active) {
this.id = id;
this.active = active;
this.age = Period.between(birthDate, LocalDate.now()).getYears();
this.birthday = birthDate;
this.name = name;
this.hashedPassword = hashedPassword;
this.addresses = addresses != null ? addresses : new HashMap<>();
if (this.age < 18) {
throw new TooYoungExc(age);
}
}
public static Customer newCustomer(
String name,
String hashedPassword,
LocalDate birthDate) {
return new Customer(
null,
name,
hashedPassword,
birthDate,
null,
true);
public static Customer newCustomer(String name, String hashedPassword, LocalDate birthDate) {
return new Customer(null, name, hashedPassword, birthDate, null, false);
}
public static Customer createCustomer(
BigInteger id,
String name,
String hashedPassword,
LocalDate birthDate,
Map<Address.AddressType, Address> addresses,
boolean active) {
return new Customer(id, name, hashedPassword, birthDate, addresses, active);
}
public void addAddresses(Map<Address.AddressType, Address> addresses) {
this.addresses.putAll(addresses);
if (this.addresses.containsKey(Address.AddressType.DEFAULT)) {
this.active = true;
}
}
public static Customer createCustomer(
BigInteger id,
String name,
String hashedPassword,
LocalDate birthDate,
Map<Address.AddressType, Address> addresses,
boolean active) {
return new Customer(
id, name, hashedPassword,
birthDate,
addresses,
active);
}
public void activateCustomer() {
if (this.addresses == null || this.addresses.containsKey(Address.AddressType.DEFAULT)) {
throw new DefaultAdressRequiredToActivateExc();
}
this.active = true;
}
public void addAddresses(Map<Address.AddressType, Address> addresses) {
this.addresses.putAll(addresses);
if (this.addresses.containsKey(Address.AddressType.DEFAULT)) {
this.activateCustomer();
}
public void deactivate() {
if (this.age < 50) {
this.active = false;
} else {
throw new TooOldToDeactivateExc(this.age);
}
}
}

View File

@@ -0,0 +1,7 @@
package de.strasser.peter.hexagonal.application.customer.exception;
public class TooOldToDeactivateExc extends BusinessException {
public TooOldToDeactivateExc(int age) {
super("Customer is too old to be deactivated. Expected < 50, Actual: " + age);
}
}

View File

@@ -2,8 +2,8 @@ package de.strasser.peter.hexagonal.application.customer.exception;
import java.text.MessageFormat;
public class UserIsTooYoungExc extends BusinessException {
public UserIsTooYoungExc(int age) {
public class TooYoungExc extends BusinessException {
public TooYoungExc(int age) {
super(MessageFormat.format("Customer is too young. Expected: > 18 yrs, Actual: {0}", age));
}
}

View File

@@ -0,0 +1,61 @@
package de.strasser.peter.hexagonal.application.customer.domain;
import de.strasser.peter.hexagonal.application.customer.exception.TooOldToDeactivateExc;
import de.strasser.peter.hexagonal.application.customer.exception.TooYoungExc;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.*;
class CustomerTest {
@Test
public void should_CreateNewCustomer() {
final var customer = Customer.newCustomer("name", "pw", LocalDate.of(1980, 1, 1));
assertNotNull(customer);
assertNull(customer.getId());
}
@Test
public void should_ThrowTooYoungErr_When_CreatingNewCustomer() {
assertThrows(
TooYoungExc.class, () -> Customer.newCustomer("name", "pw", LocalDate.of(2010, 1, 1)));
}
@Test
public void should_ActivateCustomer_When_AddingDefaultAddress() {
final Customer customer = Customer.newCustomer("name", "pw", LocalDate.of(1980, 1, 1));
final Address address = new Address("Parkring", 59, 85748, "Germany");
customer.addAddresses(Collections.singletonMap(Address.AddressType.DEFAULT, address));
assertTrue(customer.isActive());
}
@Test
public void should_NotActivateCustomer_When_AddingBillingAddress() {
final Customer customer = Customer.newCustomer("name", "pw", LocalDate.of(1980, 1, 1));
final Address address = new Address("Parkring", 59, 85748, "Germany");
customer.addAddresses(Collections.singletonMap(Address.AddressType.BILLING, address));
assertFalse(customer.isActive());
}
@Test
public void should_DeactivateCustomer() {
final Customer customer = Customer.newCustomer("name", "pw", LocalDate.of(1980, 1, 1));
customer.deactivate();
assertFalse(customer.isActive());
}
@Test
public void should_ThrowTooOldErr_When_DeactivatingOldCustomer(){
final Customer customer = Customer.newCustomer("name", "pw", LocalDate.of(1950, 1, 1));
assertThrows(TooOldToDeactivateExc.class, customer::deactivate);
}
}

View File

@@ -1,26 +1,141 @@
package de.strasser.peter.hexagonal.application.customer.service;
import de.strasser.peter.hexagonal.application.customer.domain.Address;
import de.strasser.peter.hexagonal.application.customer.domain.Customer;
import de.strasser.peter.hexagonal.application.customer.mapper.AddAddressMapper;
import de.strasser.peter.hexagonal.application.customer.port.in.commands.AddAddressCommand;
import de.strasser.peter.hexagonal.application.customer.port.out.AddressValidatorAdapter;
import de.strasser.peter.hexagonal.application.customer.port.out.LoadCustomerAdapter;
import de.strasser.peter.hexagonal.application.customer.port.out.SaveCustomerAdapter;
import de.strasser.peter.hexagonal.application.customer.port.out.commands.ValidateAddressCommand;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
import java.math.BigInteger;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
@SpringBootTest
class AddressServiceTest {
@Autowired
private AddressService sut;
@MockBean
private SaveCustomerAdapter saveCustomerAdapterMock;
@MockBean
private AddressValidatorAdapter addressValidatorAdapterMock;
@MockBean
private LoadCustomerAdapter loadCustomerAdapterMock;
@MockBean
private AddAddressMapper addAddressMapperMock;
@Autowired private AddressService sut;
@MockBean private SaveCustomerAdapter saveCustomerAdapterMock;
@MockBean private AddressValidatorAdapter addressValidatorAdapterMock;
@MockBean private LoadCustomerAdapter loadCustomerAdapterMock;
@SpyBean private AddAddressMapper addAddressMapperMock;
@Test
public void should_AddAddress() {
// GIVEN
final var customerId = BigInteger.valueOf(13);
final var name = "hans";
final var birthday = LocalDate.of(1980, 1, 1);
final var passwd = "secretPw";
given(loadCustomerAdapterMock.findById(any()))
.willReturn(Customer.createCustomer(customerId, name, passwd, birthday, null, false));
final String street = "Parkring";
final int houseNumber = 57;
final int zipCode = 85748;
final String country = "Germany";
final Address validatedAddress = new Address(street, houseNumber, zipCode, country);
final ValidateAddressCommand validateAddressCmd =
new ValidateAddressCommand(street, houseNumber, zipCode, country);
given(addressValidatorAdapterMock.validate(validateAddressCmd)).willReturn(validatedAddress);
final List<AddAddressCommand> addAddressCmds =
List.of(new AddAddressCommand("billing", street, houseNumber, zipCode, country));
sut.addAddresses(customerId, addAddressCmds);
final Customer customerToBeSaved =
Customer.createCustomer(
customerId,
name,
passwd,
birthday,
Map.of(Address.AddressType.BILLING, validatedAddress),
false);
then(saveCustomerAdapterMock).should().upsert(eq(customerToBeSaved));
}
@Test
public void should_AddAddressAndActivateCustomer_When_ProvidedDefaultAddress() {
// GIVEN
final var customerId = BigInteger.valueOf(13);
final var name = "hans";
final var birthday = LocalDate.of(1980, 1, 1);
final var passwd = "secretPw";
given(loadCustomerAdapterMock.findById(any()))
.willReturn(Customer.createCustomer(customerId, name, passwd, birthday, null, false));
final String street = "Parkring";
final int houseNumber = 57;
final int zipCode = 85748;
final String country = "Germany";
final Address validatedAddress = new Address(street, houseNumber, zipCode, country);
final ValidateAddressCommand validateAddressCmd =
new ValidateAddressCommand(street, houseNumber, zipCode, country);
given(addressValidatorAdapterMock.validate(validateAddressCmd)).willReturn(validatedAddress);
final List<AddAddressCommand> addAddressCmds =
List.of(new AddAddressCommand("default", street, houseNumber, zipCode, country));
sut.addAddresses(customerId, addAddressCmds);
final Customer customerToBeSaved =
Customer.createCustomer(
customerId,
name,
passwd,
birthday,
Map.of(Address.AddressType.DEFAULT, validatedAddress),
true);
then(saveCustomerAdapterMock).should().upsert(eq(customerToBeSaved));
}
@Test
public void should_ThrowError_When_ProvidingInvalidAddress() {
// GIVEN
final var customerId = BigInteger.valueOf(13);
final var name = "hans";
final var birthday = LocalDate.of(1980, 1, 1);
final var passwd = "secretPw";
given(loadCustomerAdapterMock.findById(any()))
.willReturn(Customer.createCustomer(customerId, name, passwd, birthday, null, false));
final String street = "Parkring";
final int houseNumber = 57;
final int zipCode = 85748;
final String country = "Germany";
final ValidateAddressCommand validateAddressCmd =
new ValidateAddressCommand(street, houseNumber, zipCode, country);
given(addressValidatorAdapterMock.validate(validateAddressCmd))
.willThrow(new IllegalArgumentException("invalid adress"));
final List<AddAddressCommand> addAddressCmds =
List.of(new AddAddressCommand("billing", street, houseNumber, zipCode, country));
assertThrows(
IllegalArgumentException.class, () -> sut.addAddresses(customerId, addAddressCmds));
}
}