├── .mvn └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── src ├── test │ ├── resources │ │ ├── application.yml │ │ └── data.sql │ └── java │ │ └── ca │ │ └── neilwhite │ │ └── hrservice │ │ ├── repositories │ │ ├── EmployeeRepositoryTest.java │ │ └── DepartmentRepositoryTest.java │ │ ├── services │ │ ├── EmployeeServiceTest.java │ │ └── DepartmentServiceTest.java │ │ └── controllers │ │ ├── EmployeeControllerTest.java │ │ └── DepartmentControllerTest.java └── main │ ├── resources │ ├── application.yml │ ├── schema.sql │ └── data.sql │ └── java │ └── ca │ └── neilwhite │ └── hrservice │ ├── exceptions │ ├── EmployeeNotFoundException.java │ ├── DepartmentNotFoundException.java │ └── DepartmentAlreadyExistsException.java │ ├── models │ ├── requests │ │ ├── CreateDepartmentRequest.java │ │ └── CreateEmployeeRequest.java │ ├── Department.java │ └── Employee.java │ ├── repositories │ ├── DepartmentRepository.java │ ├── EmployeeRepository.java │ └── DepartmentRepositoryImpl.java │ ├── HRServiceApplication.java │ ├── controllers │ ├── EmployeeController.java │ ├── DepartmentController.java │ └── ControllerExceptionHandler.java │ └── services │ ├── EmployeeService.java │ └── DepartmentService.java ├── Dockerfile ├── .gitignore ├── docker-compose.yml ├── mvnw.cmd ├── pom.xml └── mvnw /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neil-writes-code/reactive-spring-demo/HEAD/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | r2dbc: 3 | url: r2dbc:tc:postgresql:///test?TC_IMAGE_TAG=14 4 | username: postgres 5 | password: postgres 6 | sql: 7 | init: 8 | mode: always -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | r2dbc: 3 | url: ${DATABASE_HOST:r2dbc:postgresql://localhost:5432/hr-service} 4 | username: ${DATABASE_USERNAME:postgres} 5 | password: ${DATABASE_PASSWORD:postgres} -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip 2 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar 3 | -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/exceptions/EmployeeNotFoundException.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.exceptions; 2 | 3 | public class EmployeeNotFoundException extends RuntimeException { 4 | public EmployeeNotFoundException(Long id) { 5 | super(String.format("Employee not found. Id: %d", id)); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/exceptions/DepartmentNotFoundException.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.exceptions; 2 | 3 | public class DepartmentNotFoundException extends RuntimeException{ 4 | public DepartmentNotFoundException(Long id) { 5 | super(String.format("Department not found. Id: %d", id)); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/exceptions/DepartmentAlreadyExistsException.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.exceptions; 2 | 3 | public class DepartmentAlreadyExistsException extends RuntimeException { 4 | public DepartmentAlreadyExistsException(String name) { 5 | super(String.format("Department with name \"%s\" already exists.", name)); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/models/requests/CreateDepartmentRequest.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.models.requests; 2 | 3 | import javax.validation.constraints.NotEmpty; 4 | import javax.validation.constraints.NotNull; 5 | 6 | public record CreateDepartmentRequest( 7 | @NotNull(message = "Name can not be null") @NotEmpty(message = "Name can not be empty") String name) { 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM dyniri/liberica-nik:java-17 2 | 3 | WORKDIR /app 4 | 5 | COPY .mvn/ .mvn/ 6 | COPY mvnw . 7 | COPY pom.xml . 8 | 9 | RUN chmod +x mvnw && \ 10 | ./mvnw -ntp dependency:go-offline 11 | 12 | COPY src/ src/ 13 | 14 | RUN ./mvnw package -Pnative -DskipTests 15 | 16 | FROM scratch 17 | 18 | COPY --from=0 /tmp /tmp 19 | COPY --from=0 /app/target/hr-service . 20 | 21 | ENTRYPOINT ["./hr-service"] 22 | 23 | -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/models/requests/CreateEmployeeRequest.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.models.requests; 2 | 3 | import javax.validation.constraints.NotEmpty; 4 | import javax.validation.constraints.NotNull; 5 | 6 | public record CreateEmployeeRequest(@NotNull @NotEmpty String firstName, @NotNull @NotEmpty String lastName, 7 | @NotNull @NotEmpty String position, @NotNull boolean isFullTime) { 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | db: 5 | image: postgres 6 | restart: always 7 | environment: 8 | POSTGRES_DB: hr-service 9 | POSTGRES_PASSWORD: postgres 10 | ports: 11 | - "5432:5432" 12 | 13 | hr-service: 14 | build: . 15 | image: hr-service 16 | environment: 17 | DATABASE_HOST: r2dbc:postgresql://db:5432/hr-service 18 | DATABASE_USERNAME: postgres 19 | DATABASE_PASSWORD: postgres 20 | ports: 21 | - "8080:8080" 22 | restart: always 23 | depends_on: 24 | - db -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/repositories/DepartmentRepository.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.repositories; 2 | 3 | import ca.neilwhite.hrservice.models.Department; 4 | import org.springframework.stereotype.Component; 5 | import reactor.core.publisher.Flux; 6 | import reactor.core.publisher.Mono; 7 | 8 | @Component 9 | public interface DepartmentRepository { 10 | Flux findAll(); 11 | 12 | Mono findById(long id); 13 | 14 | Mono findByName(String name); 15 | 16 | Mono save(Department department); 17 | 18 | Mono delete(Department department); 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/repositories/EmployeeRepository.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.repositories; 2 | 3 | import ca.neilwhite.hrservice.models.Employee; 4 | import org.springframework.data.r2dbc.repository.R2dbcRepository; 5 | import org.springframework.stereotype.Repository; 6 | import reactor.core.publisher.Flux; 7 | import reactor.core.publisher.Mono; 8 | 9 | @Repository 10 | public interface EmployeeRepository extends R2dbcRepository { 11 | Flux findAllByPosition(String position); 12 | Flux findAllByFullTime(boolean isFullTime); 13 | Flux findAllByPositionAndFullTime(String position, boolean isFullTime); 14 | Mono findByFirstName(String firstName); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/resources/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS departments( 2 | id BIGSERIAL PRIMARY KEY, 3 | name VARCHAR(255) UNIQUE NOT NULL 4 | ); 5 | 6 | CREATE TABLE IF NOT EXISTS employees( 7 | id BIGSERIAL PRIMARY KEY, 8 | first_name VARCHAR(255) NOT NULL UNIQUE, 9 | last_name VARCHAR(255) NOT NULL UNIQUE, 10 | position VARCHAR(255) NOT NULL, 11 | is_full_time BOOLEAN NOT NULL 12 | ); 13 | 14 | CREATE TABLE IF NOT EXISTS department_employees( 15 | department_id BIGSERIAL REFERENCES departments (id), 16 | employee_id BIGSERIAL UNIQUE REFERENCES employees (id), 17 | PRIMARY KEY(department_id, employee_id) 18 | ); 19 | 20 | CREATE TABLE IF NOT EXISTS department_managers( 21 | department_id BIGSERIAL UNIQUE REFERENCES departments (id), 22 | employee_id BIGSERIAL UNIQUE REFERENCES employees (id), 23 | PRIMARY KEY(department_id, employee_id) 24 | ); -------------------------------------------------------------------------------- /src/test/resources/data.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM department_employees; 2 | DELETE FROM department_managers; 3 | DELETE FROM departments; 4 | DELETE FROM employees; 5 | 6 | INSERT INTO departments(id, name) 7 | VALUES (10, 'Software Development'), 8 | (20, 'HR'); 9 | 10 | INSERT INTO employees(id, first_name, last_name, position, is_full_time) 11 | VALUES (10, 'Bob', 'Steeves', 'Director of Software Development', true), 12 | (11, 'Neil', 'White', 'Software Developer', true), 13 | (12, 'Joanna', 'Bernier', 'Software Tester', false), 14 | (13, 'Cathy', 'Ouellette', 'Director of Human Resources', true), 15 | (14, 'Alysha', 'Rogers', 'Intraday Analyst', true); 16 | 17 | INSERT INTO department_managers(department_id, employee_id) 18 | VALUES (10, 10), 19 | (20, 13); 20 | 21 | INSERT INTO department_employees(department_id, employee_id) 22 | VALUES (10, 11), 23 | (10, 12), 24 | (20, 14); -------------------------------------------------------------------------------- /src/main/resources/data.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM department_managers; 2 | DELETE FROM department_employees; 3 | DELETE FROM departments; 4 | DELETE FROM employees; 5 | 6 | ALTER SEQUENCE departments_id_seq RESTART WITH 1; 7 | ALTER SEQUENCE employees_id_seq RESTART WITH 1; 8 | 9 | INSERT INTO departments(name) 10 | VALUES ('Software Development'), 11 | ('HR'); 12 | 13 | INSERT INTO employees(first_name, last_name, position, is_full_time) 14 | VALUES ('Bob', 'Steeves', 'Director of Software Development', true), 15 | ('Neil', 'White', 'Software Developer', true), 16 | ('Joanna', 'Bernier', 'Software Tester', false), 17 | ('Cathy', 'Ouellette', 'Director of Human Resources', true), 18 | ('Alysha', 'Rogers', 'Intraday Analyst', true); 19 | 20 | INSERT INTO department_managers(department_id, employee_id) 21 | VALUES (1, 1), 22 | (2, 4); 23 | 24 | INSERT INTO department_employees(department_id, employee_id) 25 | VALUES (1, 2), 26 | (1, 3), 27 | (2, 5); -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/models/Department.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.models; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | import org.springframework.data.annotation.Id; 8 | import org.springframework.data.relational.core.mapping.Table; 9 | import reactor.core.publisher.Mono; 10 | 11 | import java.util.*; 12 | 13 | @Data 14 | @Builder 15 | @NoArgsConstructor 16 | @AllArgsConstructor 17 | @Table("departments") 18 | public class Department { 19 | @Id 20 | private Long id; 21 | private String name; 22 | private Employee manager; 23 | 24 | @Builder.Default 25 | private List employees = new ArrayList<>(); 26 | 27 | public Optional getManager(){ 28 | return Optional.ofNullable(this.manager); 29 | } 30 | 31 | public static Mono fromRows(List> rows) { 32 | return Mono.just(Department.builder() 33 | .id((Long.parseLong(rows.get(0).get("d_id").toString()))) 34 | .name((String) rows.get(0).get("d_name")) 35 | .manager(Employee.managerFromRow(rows.get(0))) 36 | .employees(rows.stream() 37 | .map(Employee::fromRow) 38 | .filter(Objects::nonNull) 39 | .toList()) 40 | .build()); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/HRServiceApplication.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice; 2 | 3 | import io.r2dbc.spi.ConnectionFactory; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Profile; 8 | import org.springframework.core.io.ClassPathResource; 9 | import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; 10 | import org.springframework.r2dbc.connection.init.CompositeDatabasePopulator; 11 | import org.springframework.r2dbc.connection.init.ConnectionFactoryInitializer; 12 | import org.springframework.r2dbc.connection.init.ResourceDatabasePopulator; 13 | 14 | @SpringBootApplication 15 | @EnableR2dbcRepositories 16 | public class HRServiceApplication { 17 | 18 | public static void main(String[] args) { 19 | SpringApplication.run(HRServiceApplication.class, args); 20 | } 21 | 22 | @Bean 23 | @Profile("default") 24 | public ConnectionFactoryInitializer initializer(ConnectionFactory connectionFactory) { 25 | 26 | ConnectionFactoryInitializer initializer = new ConnectionFactoryInitializer(); 27 | initializer.setConnectionFactory(connectionFactory); 28 | 29 | CompositeDatabasePopulator populator = new CompositeDatabasePopulator(); 30 | populator.addPopulators(new ResourceDatabasePopulator(new ClassPathResource("schema.sql"))); 31 | populator.addPopulators(new ResourceDatabasePopulator(new ClassPathResource("data.sql"))); 32 | initializer.setDatabasePopulator(populator); 33 | 34 | return initializer; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/controllers/EmployeeController.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.controllers; 2 | 3 | import ca.neilwhite.hrservice.models.Employee; 4 | import ca.neilwhite.hrservice.models.requests.CreateEmployeeRequest; 5 | import ca.neilwhite.hrservice.services.EmployeeService; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.http.HttpStatus; 8 | import org.springframework.web.bind.annotation.*; 9 | import reactor.core.publisher.Flux; 10 | import reactor.core.publisher.Mono; 11 | 12 | import javax.validation.Valid; 13 | 14 | @RestController 15 | @RequiredArgsConstructor 16 | @RequestMapping("/employees") 17 | public class EmployeeController { 18 | private final EmployeeService service; 19 | 20 | @GetMapping 21 | public Flux getEmployees(@RequestParam(required = false) String position, @RequestParam(name = "fullTime", required = false) Boolean isFullTime) { 22 | return this.service.getEmployees(position, isFullTime); 23 | } 24 | 25 | @GetMapping("/{id}") 26 | public Mono getEmployee(@PathVariable Long id) { 27 | return this.service.getEmployee(id); 28 | } 29 | 30 | @PostMapping 31 | @ResponseStatus(HttpStatus.CREATED) 32 | public Mono createEmployee(@Valid @RequestBody CreateEmployeeRequest request) { 33 | return this.service.createEmployee(request); 34 | } 35 | 36 | @PutMapping("/{id}") 37 | public Mono updateEmployee(@PathVariable Long id, Employee employee) { 38 | return this.service.updateEmployee(id, employee); 39 | } 40 | 41 | @DeleteMapping("/{id}") 42 | public Mono deleteEmployee(@PathVariable Long id) { 43 | return this.service.deleteEmployee(id); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/models/Employee.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.models; 2 | 3 | import lombok.*; 4 | import org.springframework.data.annotation.Id; 5 | import org.springframework.data.relational.core.mapping.Column; 6 | import org.springframework.data.relational.core.mapping.Table; 7 | 8 | import java.util.Map; 9 | 10 | @Data 11 | @Builder 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | @Table("employees") 15 | public class Employee { 16 | @Id 17 | private Long id; 18 | private String firstName; 19 | private String lastName; 20 | private String position; 21 | 22 | @Column("is_full_time") 23 | private boolean fullTime; 24 | 25 | public static Employee fromRow(Map row) { 26 | if (row.get("e_id") != null) { 27 | return Employee.builder() 28 | .id((Long.parseLong(row.get("e_id").toString()))) 29 | .firstName((String) row.get("e_firstName")) 30 | .lastName((String) row.get("e_lastName")) 31 | .position((String) row.get("e_position")) 32 | .fullTime((Boolean) row.get("e_isFullTime")) 33 | .build(); 34 | } else { 35 | return null; 36 | } 37 | 38 | } 39 | 40 | public static Employee managerFromRow(Map row) { 41 | if (row.get("m_id") != null) { 42 | return Employee.builder() 43 | .id((Long.parseLong(row.get("m_id").toString()))) 44 | .firstName((String) row.get("m_firstName")) 45 | .lastName((String) row.get("m_lastName")) 46 | .position((String) row.get("m_position")) 47 | .fullTime((Boolean) row.get("m_isFullTime")) 48 | .build(); 49 | } else { 50 | return null; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/controllers/DepartmentController.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.controllers; 2 | 3 | import ca.neilwhite.hrservice.models.Department; 4 | import ca.neilwhite.hrservice.models.Employee; 5 | import ca.neilwhite.hrservice.models.requests.CreateDepartmentRequest; 6 | import ca.neilwhite.hrservice.services.DepartmentService; 7 | import lombok.RequiredArgsConstructor; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.web.bind.annotation.*; 10 | import reactor.core.publisher.Flux; 11 | import reactor.core.publisher.Mono; 12 | 13 | import javax.validation.Valid; 14 | 15 | @RestController 16 | @RequiredArgsConstructor 17 | @RequestMapping("/departments") 18 | public class DepartmentController { 19 | private final DepartmentService service; 20 | 21 | @GetMapping 22 | public Flux getDepartments() { 23 | return this.service.getDepartments(); 24 | } 25 | 26 | @GetMapping("/{id}") 27 | public Mono getDepartment(@PathVariable Long id) { 28 | return this.service.getDepartment(id); 29 | } 30 | 31 | @GetMapping("/{id}/employees") 32 | public Flux getDepartmentEmployees(@PathVariable Long id, @RequestParam(name = "fullTime", required = false) Boolean isFullTime) { 33 | return this.service.getDepartmentEmployees(id, isFullTime); 34 | } 35 | 36 | @PostMapping 37 | @ResponseStatus(HttpStatus.CREATED) 38 | public Mono createDepartment(@Valid @RequestBody CreateDepartmentRequest request) { 39 | return this.service.createDepartment(request); 40 | } 41 | 42 | @PutMapping("/{id}") 43 | public Mono updateDepartment(@PathVariable Long id, @RequestBody Department department) { 44 | return this.service.updateDepartment(id, department); 45 | } 46 | 47 | @DeleteMapping("/{id}") 48 | public Mono deleteDepartment(@PathVariable Long id) { 49 | return this.service.deleteDepartment(id); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/controllers/ControllerExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.controllers; 2 | 3 | import ca.neilwhite.hrservice.exceptions.DepartmentAlreadyExistsException; 4 | import ca.neilwhite.hrservice.exceptions.DepartmentNotFoundException; 5 | import ca.neilwhite.hrservice.exceptions.EmployeeNotFoundException; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.context.support.DefaultMessageSourceResolvable; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.http.ResponseEntity; 10 | import org.springframework.web.bind.annotation.ExceptionHandler; 11 | import org.springframework.web.bind.annotation.RestControllerAdvice; 12 | import org.springframework.web.bind.support.WebExchangeBindException; 13 | 14 | import java.util.List; 15 | import java.util.stream.Collectors; 16 | 17 | @Slf4j 18 | @RestControllerAdvice 19 | public class ControllerExceptionHandler { 20 | 21 | @ExceptionHandler({ 22 | DepartmentNotFoundException.class, 23 | EmployeeNotFoundException.class 24 | }) 25 | ResponseEntity handleNotFound(RuntimeException exception) { 26 | log.debug("handling exception:: " + exception); 27 | return ResponseEntity.status(HttpStatus.NOT_FOUND).body(exception.getMessage()); 28 | } 29 | 30 | @ExceptionHandler({DepartmentAlreadyExistsException.class}) 31 | ResponseEntity handleBadRequest(RuntimeException exception) { 32 | log.debug("handling exception:: " + exception); 33 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(exception.getMessage()); 34 | } 35 | 36 | @ExceptionHandler(WebExchangeBindException.class) 37 | public ResponseEntity> handleException(WebExchangeBindException e) { 38 | List errors = e.getBindingResult() 39 | .getAllErrors() 40 | .stream() 41 | .map(DefaultMessageSourceResolvable::getDefaultMessage) 42 | .toList(); 43 | return ResponseEntity.badRequest().body(errors); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/services/EmployeeService.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.services; 2 | 3 | import ca.neilwhite.hrservice.exceptions.EmployeeNotFoundException; 4 | import ca.neilwhite.hrservice.models.Employee; 5 | import ca.neilwhite.hrservice.models.requests.CreateEmployeeRequest; 6 | import ca.neilwhite.hrservice.repositories.EmployeeRepository; 7 | import lombok.RequiredArgsConstructor; 8 | import org.springframework.stereotype.Service; 9 | import reactor.core.publisher.Flux; 10 | import reactor.core.publisher.Mono; 11 | 12 | @Service 13 | @RequiredArgsConstructor 14 | public class EmployeeService { 15 | private final EmployeeRepository repository; 16 | 17 | /** 18 | * Returns all Employees, optionally filtered by position or full time status. 19 | * 20 | * @param position Employee Position 21 | * @param isFullTime Is Employee Full Time 22 | * @return Flux of {@link Employee} 23 | */ 24 | public Flux getEmployees(String position, Boolean isFullTime) { 25 | if (position != null) { 26 | if (isFullTime != null) { 27 | return this.repository.findAllByPositionAndFullTime(position, isFullTime); 28 | } else { 29 | return this.repository.findAllByPosition(position); 30 | } 31 | } else { 32 | if (isFullTime != null) { 33 | return this.repository.findAllByFullTime(isFullTime); 34 | } else { 35 | return this.repository.findAll(); 36 | } 37 | } 38 | } 39 | 40 | /** 41 | * Returns an Employee by ID. 42 | * 43 | * @param id Employee ID 44 | * @return Mono of {@link Employee} 45 | */ 46 | public Mono getEmployee(Long id) { 47 | return this.repository.findById(id) 48 | .switchIfEmpty(Mono.error(new EmployeeNotFoundException(id))); 49 | } 50 | 51 | /** 52 | * Creates and returns a new Employee. 53 | * 54 | * @param request {@link CreateEmployeeRequest} 55 | * @return Mono of {@link Employee} 56 | */ 57 | public Mono createEmployee(CreateEmployeeRequest request) { 58 | return this.repository.save( 59 | Employee.builder() 60 | .firstName(request.firstName()) 61 | .lastName(request.lastName()) 62 | .position(request.position()) 63 | .fullTime(request.isFullTime()) 64 | .build()); 65 | } 66 | 67 | /** 68 | * Updates and returns an Employee. 69 | * 70 | * @param id Employee ID 71 | * @param employee {@link Employee} 72 | * @return Mono of {@link Employee} 73 | */ 74 | public Mono updateEmployee(Long id, Employee employee) { 75 | return this.repository.findById(id) 76 | .switchIfEmpty(Mono.error(new EmployeeNotFoundException(id))) 77 | .flatMap(existingEmployee -> { 78 | existingEmployee.setFirstName(employee.getFirstName()); 79 | existingEmployee.setLastName(employee.getLastName()); 80 | existingEmployee.setPosition(employee.getPosition()); 81 | existingEmployee.setFullTime(employee.isFullTime()); 82 | return this.repository.save(existingEmployee); 83 | }); 84 | } 85 | 86 | /** 87 | * Deletes an Employee by ID. 88 | * 89 | * @param id Employee ID 90 | * @return Mono of {@link Void} 91 | */ 92 | public Mono deleteEmployee(Long id) { 93 | return this.repository.findById(id) 94 | .switchIfEmpty(Mono.error(new EmployeeNotFoundException(id))) 95 | .flatMap(this.repository::delete) 96 | .then(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/services/DepartmentService.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.services; 2 | 3 | import ca.neilwhite.hrservice.exceptions.DepartmentAlreadyExistsException; 4 | import ca.neilwhite.hrservice.exceptions.DepartmentNotFoundException; 5 | import ca.neilwhite.hrservice.models.Department; 6 | import ca.neilwhite.hrservice.models.Employee; 7 | import ca.neilwhite.hrservice.models.requests.CreateDepartmentRequest; 8 | import ca.neilwhite.hrservice.repositories.DepartmentRepository; 9 | import lombok.RequiredArgsConstructor; 10 | import org.springframework.stereotype.Service; 11 | import reactor.core.publisher.Flux; 12 | import reactor.core.publisher.Mono; 13 | 14 | @Service 15 | @RequiredArgsConstructor 16 | public class DepartmentService { 17 | private final DepartmentRepository repository; 18 | 19 | /** 20 | * Returns all Departments. 21 | * 22 | * @return Flux of {@link Department} 23 | */ 24 | public Flux getDepartments() { 25 | return this.repository.findAll(); 26 | } 27 | 28 | /** 29 | * Returns a Department by ID. 30 | * 31 | * @param id Department ID 32 | * @return Mono of {@link Department} 33 | */ 34 | public Mono getDepartment(Long id) { 35 | return this.repository.findById(id) 36 | .switchIfEmpty(Mono.error(new DepartmentNotFoundException(id))); 37 | } 38 | 39 | /** 40 | * Returns the Employees of a Department by ID. 41 | * 42 | * @param id Department ID 43 | * @param isFullTime Filter employees on full time status 44 | * @return Flux of {@link Employee} 45 | */ 46 | public Flux getDepartmentEmployees(Long id, Boolean isFullTime) { 47 | if (isFullTime != null) { 48 | return this.repository.findById(id) 49 | .switchIfEmpty(Mono.error(new DepartmentNotFoundException(id))) 50 | .flatMapMany(department -> 51 | Flux.fromStream(department.getEmployees() 52 | .stream() 53 | .filter(employee -> employee.isFullTime() == isFullTime))); 54 | } else { 55 | return this.repository.findById(id) 56 | .switchIfEmpty(Mono.error(new DepartmentNotFoundException(id))) 57 | .flatMapMany(department -> Flux.fromIterable(department.getEmployees())); 58 | } 59 | } 60 | 61 | /** 62 | * Creates and returns a new Department. 63 | * 64 | * @param request {@link CreateDepartmentRequest} 65 | * @return Mono of {@link Department} 66 | */ 67 | public Mono createDepartment(CreateDepartmentRequest request) { 68 | return this.repository.findByName(request.name()) 69 | .flatMap(department -> Mono.error(new DepartmentAlreadyExistsException(department.getName()))) 70 | .defaultIfEmpty(Department.builder().name(request.name()).build()).cast(Department.class) 71 | .flatMap(this.repository::save); 72 | } 73 | 74 | /** 75 | * Updates and returns a Department. 76 | * 77 | * @param id Department ID 78 | * @param department {@link Department} 79 | * @return Mono of {@link Department} 80 | */ 81 | public Mono updateDepartment(Long id, Department department) { 82 | return this.repository.findById(id) 83 | .switchIfEmpty(Mono.error(new DepartmentNotFoundException(id))) 84 | .doOnNext(currentDepartment -> { 85 | currentDepartment.setName(department.getName()); 86 | 87 | if(department.getManager().isPresent()){ 88 | currentDepartment.setManager(department.getManager().get()); 89 | } 90 | 91 | currentDepartment.setEmployees(department.getEmployees()); 92 | }) 93 | .flatMap(this.repository::save); 94 | } 95 | 96 | /** 97 | * Deletes a Department by ID. 98 | * 99 | * @param id Department ID 100 | * @return Mono of {@link Void} 101 | */ 102 | public Mono deleteDepartment(Long id) { 103 | return this.repository.findById(id) 104 | .switchIfEmpty(Mono.error(new DepartmentNotFoundException(id))) 105 | .flatMap(this.repository::delete) 106 | .then(); 107 | } 108 | } 109 | 110 | 111 | -------------------------------------------------------------------------------- /src/test/java/ca/neilwhite/hrservice/repositories/EmployeeRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.repositories; 2 | 3 | import ca.neilwhite.hrservice.models.Employee; 4 | import org.junit.jupiter.api.DisplayName; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest; 8 | import org.springframework.test.annotation.DirtiesContext; 9 | import org.testcontainers.junit.jupiter.Testcontainers; 10 | import reactor.test.StepVerifier; 11 | 12 | import static org.junit.jupiter.api.Assertions.assertEquals; 13 | 14 | @DataR2dbcTest 15 | @Testcontainers 16 | class EmployeeRepositoryTest { 17 | @Autowired 18 | private EmployeeRepository repository; 19 | 20 | @Test 21 | @DirtiesContext 22 | @DisplayName("save() should return an Employee") 23 | void save_shouldReturnEmployee() { 24 | Employee newEmployee = Employee.builder() 25 | .firstName("Jane") 26 | .lastName("Smith") 27 | .position("Accountant") 28 | .fullTime(true) 29 | .build(); 30 | 31 | this.repository.save(newEmployee).log() 32 | .as(StepVerifier::create) 33 | .consumeNextWith(employee -> assertEquals(newEmployee, employee)) 34 | .verifyComplete(); 35 | } 36 | 37 | @Test 38 | @DisplayName("findAll() should return 5 Employees") 39 | void findAll_shouldReturnEmployees() { 40 | this.repository.findAll() 41 | .as(StepVerifier::create) 42 | .expectNextCount(5) 43 | .verifyComplete(); 44 | } 45 | 46 | @Test 47 | @DisplayName("findById(11) should return an Employee") 48 | void findById_shouldReturnEmployee() { 49 | this.repository.findById(11L) 50 | .as(StepVerifier::create) 51 | .consumeNextWith(employee -> assertEquals(stubbedEmployee(), employee)) 52 | .verifyComplete(); 53 | } 54 | 55 | @Test 56 | @DisplayName("findById(9) should not return an Employee") 57 | void findById_shouldNotReturnEmployee() { 58 | this.repository.findById(9L) 59 | .as(StepVerifier::create) 60 | .expectNextCount(0) 61 | .verifyComplete(); 62 | } 63 | 64 | @Test 65 | @DisplayName("findAllByPosition(\"Software Developer\") should return an Employee") 66 | void findAllByPosition_shouldReturnEmployee() { 67 | this.repository.findAllByPosition("Software Developer") 68 | .as(StepVerifier::create) 69 | .consumeNextWith(employee -> assertEquals(stubbedEmployee(), employee)) 70 | .verifyComplete(); 71 | } 72 | 73 | @Test 74 | @DisplayName("findAllByPosition(\"Marketing\") should not return an Employee") 75 | void findAllByPosition_shouldNotReturnEmployee() { 76 | this.repository.findAllByPosition("Marketing") 77 | .as(StepVerifier::create) 78 | .expectNextCount(0) 79 | .verifyComplete(); 80 | } 81 | 82 | @Test 83 | @DisplayName("findAllByFullTime(true) should return 4 Employees") 84 | void findAllByFullTime_True_shouldReturnEmployees() { 85 | this.repository.findAllByFullTime(true) 86 | .as(StepVerifier::create) 87 | .expectNextCount(4) 88 | .verifyComplete(); 89 | } 90 | 91 | @Test 92 | @DisplayName("findAllByFullTime(false) should return 1 Employee") 93 | void findAllByFullTime_False_shouldReturnEmployees() { 94 | this.repository.findAllByFullTime(false) 95 | .as(StepVerifier::create) 96 | .expectNextCount(1) 97 | .verifyComplete(); 98 | } 99 | 100 | @Test 101 | @DisplayName("findByFirstName(\"Neil\") should return an Employee") 102 | void findByFirstName_shouldReturnEmployee() { 103 | this.repository.findByFirstName("Neil") 104 | .as(StepVerifier::create) 105 | .consumeNextWith(employee -> assertEquals(stubbedEmployee(), employee)) 106 | .verifyComplete(); 107 | } 108 | 109 | @Test 110 | @DisplayName("findByFirstName(\"Steve\") should not return an Employee") 111 | void findByFirstName_shouldNotReturnEmployee() { 112 | this.repository.findByFirstName("Steve") 113 | .as(StepVerifier::create) 114 | .expectNextCount(0) 115 | .verifyComplete(); 116 | } 117 | 118 | private Employee stubbedEmployee() { 119 | return Employee.builder() 120 | .id(11L) 121 | .firstName("Neil") 122 | .lastName("White") 123 | .position("Software Developer") 124 | .fullTime(true) 125 | .build(); 126 | } 127 | } -------------------------------------------------------------------------------- /src/test/java/ca/neilwhite/hrservice/services/EmployeeServiceTest.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.services; 2 | 3 | import ca.neilwhite.hrservice.exceptions.EmployeeNotFoundException; 4 | import ca.neilwhite.hrservice.models.Employee; 5 | import ca.neilwhite.hrservice.models.requests.CreateEmployeeRequest; 6 | import ca.neilwhite.hrservice.repositories.EmployeeRepository; 7 | import org.junit.jupiter.api.DisplayName; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.mockito.InjectMocks; 11 | import org.mockito.Mock; 12 | import org.mockito.junit.jupiter.MockitoExtension; 13 | import reactor.core.publisher.Flux; 14 | import reactor.core.publisher.Mono; 15 | import reactor.test.StepVerifier; 16 | 17 | import static org.junit.jupiter.api.Assertions.assertEquals; 18 | import static org.mockito.ArgumentMatchers.*; 19 | import static org.mockito.Mockito.when; 20 | 21 | @ExtendWith(MockitoExtension.class) 22 | class EmployeeServiceTest { 23 | @Mock 24 | private EmployeeRepository repository; 25 | @InjectMocks 26 | private EmployeeService service; 27 | 28 | @Test 29 | @DisplayName("getEmployees(null, null) should return 1 Employee") 30 | void getEmployees_shouldReturnEmployees() { 31 | when(this.repository.findAll()).thenReturn(Flux.just(stubbedEmployee())); 32 | 33 | this.service.getEmployees(null, null) 34 | .as(StepVerifier::create) 35 | .expectNextCount(1) 36 | .verifyComplete(); 37 | } 38 | 39 | @Test 40 | @DisplayName("getEmployees(\"Software Developer\", null) should return 1 Employee") 41 | void getEmployeesByPosition_shouldReturnEmployees() { 42 | when(this.repository.findAllByPosition(anyString())).thenReturn(Flux.just(stubbedEmployee())); 43 | 44 | this.service.getEmployees("Software Developer", null) 45 | .as(StepVerifier::create) 46 | .expectNextCount(1) 47 | .verifyComplete(); 48 | } 49 | 50 | @Test 51 | @DisplayName("getEmployees(null, true) should return 1 Employee") 52 | void getEmployeesByFullTime_shouldReturnEmployees() { 53 | when(this.repository.findAllByFullTime(anyBoolean())).thenReturn(Flux.just(stubbedEmployee())); 54 | 55 | this.service.getEmployees(null, true) 56 | .as(StepVerifier::create) 57 | .expectNextCount(1) 58 | .verifyComplete(); 59 | } 60 | 61 | @Test 62 | @DisplayName("getEmployees(\"Software Developer\", true) should return 1 Employee") 63 | void getEmployeesByPositionAndFullTime_shouldReturnEmployees() { 64 | when(this.repository.findAllByPositionAndFullTime(anyString(), anyBoolean())).thenReturn(Flux.just(stubbedEmployee())); 65 | 66 | this.service.getEmployees("Software Developer", true) 67 | .as(StepVerifier::create) 68 | .expectNextCount(1) 69 | .verifyComplete(); 70 | } 71 | 72 | @Test 73 | @DisplayName("getEmployee(1) should return an Employee") 74 | void getEmployee_shouldReturnEmployee() { 75 | when(this.repository.findById(anyLong())).thenReturn(Mono.just(stubbedEmployee())); 76 | 77 | this.service.getEmployee(1L) 78 | .as(StepVerifier::create) 79 | .consumeNextWith(employee -> assertEquals(stubbedEmployee(), employee)) 80 | .verifyComplete(); 81 | } 82 | 83 | @Test 84 | @DisplayName("getEmployee(2) should throw EmployeeNotFoundException") 85 | void getEmployee_shouldThrowEmployeeNotFound() { 86 | when(this.repository.findById(anyLong())).thenReturn(Mono.empty()); 87 | 88 | this.service.getEmployee(2L) 89 | .as(StepVerifier::create) 90 | .expectError(EmployeeNotFoundException.class) 91 | .verify(); 92 | } 93 | 94 | @Test 95 | @DisplayName("createEmployee(request) should return an Employee") 96 | void createEmployee_shouldReturnEmployee() { 97 | Employee newEmployee = Employee.builder() 98 | .firstName("Bob") 99 | .lastName("Walker") 100 | .position("Dog Walker") 101 | .fullTime(false) 102 | .build(); 103 | 104 | when(this.repository.save(any(Employee.class))).thenReturn(Mono.just(newEmployee)); 105 | 106 | this.service.createEmployee(new CreateEmployeeRequest("Bob", "Walker", "Dog Walker", false)) 107 | .as(StepVerifier::create) 108 | .consumeNextWith(employee -> assertEquals(newEmployee, employee)) 109 | .verifyComplete(); 110 | } 111 | 112 | @Test 113 | @DisplayName("updateEmployee(1, employee) should return an updated Employee") 114 | void updateEmployee_shouldReturnEmployee() { 115 | Employee updatedEmployee = stubbedEmployee(); 116 | 117 | updatedEmployee.setFirstName("George"); 118 | 119 | when(this.repository.findById(anyLong())).thenReturn(Mono.just(stubbedEmployee())); 120 | when(this.repository.save(any(Employee.class))).thenReturn(Mono.just(updatedEmployee)); 121 | 122 | this.service.updateEmployee(1L, updatedEmployee) 123 | .as(StepVerifier::create) 124 | .consumeNextWith(employee -> assertEquals(updatedEmployee, employee)) 125 | .verifyComplete(); 126 | } 127 | 128 | @Test 129 | @DisplayName("updateEmployee(2, employee) should throw EmployeeNotFoundException") 130 | void updateEmployee_shouldThrowEmployeeNotFound() { 131 | when(this.repository.findById(anyLong())).thenReturn(Mono.empty()); 132 | 133 | this.service.updateEmployee(2L, stubbedEmployee()) 134 | .as(StepVerifier::create) 135 | .expectError(EmployeeNotFoundException.class) 136 | .verify(); 137 | } 138 | 139 | @Test 140 | @DisplayName("deleteEmployee(1) should complete") 141 | void deleteEmployee_shouldDeleteEmployee() { 142 | when(this.repository.findById(anyLong())).thenReturn(Mono.just(stubbedEmployee())); 143 | when(this.repository.delete(any(Employee.class))).thenReturn(Mono.empty()); 144 | 145 | this.service.deleteEmployee(1L) 146 | .as(StepVerifier::create) 147 | .verifyComplete(); 148 | } 149 | 150 | @Test 151 | @DisplayName("deleteEmployee(2) should throw EmployeeNotFoundException") 152 | void deleteEmployee_shouldReturnEmployeeNotFound() { 153 | when(this.repository.findById(anyLong())).thenReturn(Mono.empty()); 154 | 155 | this.service.deleteEmployee(2L) 156 | .as(StepVerifier::create) 157 | .expectError(EmployeeNotFoundException.class) 158 | .verify(); 159 | } 160 | 161 | private Employee stubbedEmployee() { 162 | return Employee.builder() 163 | .id(1L) 164 | .firstName("Neil") 165 | .lastName("White") 166 | .position("Software Developer") 167 | .fullTime(true) 168 | .build(); 169 | } 170 | } -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM https://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Maven Start Up Batch script 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM M2_HOME - location of maven2's installed home dir 28 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 29 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 30 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | @REM e.g. to debug Maven itself, use 32 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | @REM ---------------------------------------------------------------------------- 35 | 36 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 37 | @echo off 38 | @REM set title of command window 39 | title %0 40 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 41 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 42 | 43 | @REM set %HOME% to equivalent of $HOME 44 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 45 | 46 | @REM Execute a user defined script before this one 47 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 48 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 49 | if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* 50 | if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* 51 | :skipRcPre 52 | 53 | @setlocal 54 | 55 | set ERROR_CODE=0 56 | 57 | @REM To isolate internal variables from possible post scripts, we use another setlocal 58 | @setlocal 59 | 60 | @REM ==== START VALIDATION ==== 61 | if not "%JAVA_HOME%" == "" goto OkJHome 62 | 63 | echo. 64 | echo Error: JAVA_HOME not found in your environment. >&2 65 | echo Please set the JAVA_HOME variable in your environment to match the >&2 66 | echo location of your Java installation. >&2 67 | echo. 68 | goto error 69 | 70 | :OkJHome 71 | if exist "%JAVA_HOME%\bin\java.exe" goto init 72 | 73 | echo. 74 | echo Error: JAVA_HOME is set to an invalid directory. >&2 75 | echo JAVA_HOME = "%JAVA_HOME%" >&2 76 | echo Please set the JAVA_HOME variable in your environment to match the >&2 77 | echo location of your Java installation. >&2 78 | echo. 79 | goto error 80 | 81 | @REM ==== END VALIDATION ==== 82 | 83 | :init 84 | 85 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 86 | @REM Fallback to current working directory if not found. 87 | 88 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 89 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 90 | 91 | set EXEC_DIR=%CD% 92 | set WDIR=%EXEC_DIR% 93 | :findBaseDir 94 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 95 | cd .. 96 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 97 | set WDIR=%CD% 98 | goto findBaseDir 99 | 100 | :baseDirFound 101 | set MAVEN_PROJECTBASEDIR=%WDIR% 102 | cd "%EXEC_DIR%" 103 | goto endDetectBaseDir 104 | 105 | :baseDirNotFound 106 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 107 | cd "%EXEC_DIR%" 108 | 109 | :endDetectBaseDir 110 | 111 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 112 | 113 | @setlocal EnableExtensions EnableDelayedExpansion 114 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 115 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 116 | 117 | :endReadAdditionalConfig 118 | 119 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 120 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 121 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 122 | 123 | set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 124 | 125 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 126 | IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B 127 | ) 128 | 129 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 130 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 131 | if exist %WRAPPER_JAR% ( 132 | if "%MVNW_VERBOSE%" == "true" ( 133 | echo Found %WRAPPER_JAR% 134 | ) 135 | ) else ( 136 | if not "%MVNW_REPOURL%" == "" ( 137 | SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 138 | ) 139 | if "%MVNW_VERBOSE%" == "true" ( 140 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 141 | echo Downloading from: %DOWNLOAD_URL% 142 | ) 143 | 144 | powershell -Command "&{"^ 145 | "$webclient = new-object System.Net.WebClient;"^ 146 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 147 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 148 | "}"^ 149 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ 150 | "}" 151 | if "%MVNW_VERBOSE%" == "true" ( 152 | echo Finished downloading %WRAPPER_JAR% 153 | ) 154 | ) 155 | @REM End of extension 156 | 157 | @REM Provide a "standardized" way to retrieve the CLI args that will 158 | @REM work with both Windows and non-Windows executions. 159 | set MAVEN_CMD_LINE_ARGS=%* 160 | 161 | %MAVEN_JAVA_EXE% ^ 162 | %JVM_CONFIG_MAVEN_PROPS% ^ 163 | %MAVEN_OPTS% ^ 164 | %MAVEN_DEBUG_OPTS% ^ 165 | -classpath %WRAPPER_JAR% ^ 166 | "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ 167 | %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 168 | if ERRORLEVEL 1 goto error 169 | goto end 170 | 171 | :error 172 | set ERROR_CODE=1 173 | 174 | :end 175 | @endlocal & set ERROR_CODE=%ERROR_CODE% 176 | 177 | if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost 178 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 179 | if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" 180 | if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" 181 | :skipRcPost 182 | 183 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 184 | if "%MAVEN_BATCH_PAUSE%"=="on" pause 185 | 186 | if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% 187 | 188 | cmd /C exit /B %ERROR_CODE% 189 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 2.7.4 9 | 10 | 11 | ca.neilwhite 12 | hr-service 13 | 0.0.1-SNAPSHOT 14 | hr-service 15 | Webflux/R2DBC demo 16 | 17 | 17 18 | 19 | 0.12.1 20 | 1.17.3 21 | 22 | 23 | 24 | org.springframework.boot 25 | spring-boot-starter-webflux 26 | 27 | 28 | org.springframework.boot 29 | spring-boot-starter-validation 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-starter-data-r2dbc 34 | 35 | 36 | org.springframework.experimental 37 | spring-native 38 | ${spring-native.version} 39 | 40 | 41 | org.postgresql 42 | postgresql 43 | 42.5.0 44 | runtime 45 | 46 | 47 | org.postgresql 48 | r2dbc-postgresql 49 | runtime 50 | 51 | 52 | org.projectlombok 53 | lombok 54 | true 55 | 56 | 57 | org.springframework.boot 58 | spring-boot-starter-test 59 | test 60 | 61 | 62 | io.projectreactor 63 | reactor-test 64 | test 65 | 66 | 67 | org.testcontainers 68 | junit-jupiter 69 | test 70 | 71 | 72 | org.testcontainers 73 | postgresql 74 | test 75 | 76 | 77 | org.testcontainers 78 | r2dbc 79 | test 80 | 81 | 82 | 83 | 84 | 85 | org.testcontainers 86 | testcontainers-bom 87 | ${testcontainers.version} 88 | pom 89 | import 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | org.springframework.boot 98 | spring-boot-maven-plugin 99 | 100 | 101 | 102 | org.projectlombok 103 | lombok 104 | 105 | 106 | ${repackage.classifier} 107 | 108 | paketobuildpacks/builder:tiny 109 | 110 | true 111 | 112 | 113 | 114 | 115 | 116 | org.springframework.experimental 117 | spring-aot-maven-plugin 118 | ${spring-native.version} 119 | 120 | 121 | test-generate 122 | 123 | test-generate 124 | 125 | 126 | 127 | generate 128 | 129 | generate 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | spring-releases 139 | Spring Releases 140 | https://repo.spring.io/release 141 | 142 | false 143 | 144 | 145 | 146 | 147 | 148 | spring-releases 149 | Spring Releases 150 | https://repo.spring.io/release 151 | 152 | false 153 | 154 | 155 | 156 | 157 | 158 | 159 | native 160 | 161 | exec 162 | 0.9.13 163 | 164 | 165 | 166 | org.junit.platform 167 | junit-platform-launcher 168 | test 169 | 170 | 171 | 172 | 173 | 174 | org.graalvm.buildtools 175 | native-maven-plugin 176 | ${native-buildtools.version} 177 | true 178 | 179 | 180 | test-native 181 | test 182 | 183 | test 184 | 185 | 186 | 187 | build-native 188 | package 189 | 190 | build 191 | 192 | 193 | 194 | 195 | 196 | --static 197 | 198 | 199 | true 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | -------------------------------------------------------------------------------- /src/test/java/ca/neilwhite/hrservice/controllers/EmployeeControllerTest.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.controllers; 2 | 3 | import ca.neilwhite.hrservice.exceptions.EmployeeNotFoundException; 4 | import ca.neilwhite.hrservice.models.Employee; 5 | import ca.neilwhite.hrservice.models.requests.CreateEmployeeRequest; 6 | import ca.neilwhite.hrservice.repositories.EmployeeRepository; 7 | import ca.neilwhite.hrservice.services.EmployeeService; 8 | import org.junit.jupiter.api.DisplayName; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; 12 | import org.springframework.boot.test.mock.mockito.MockBean; 13 | import org.springframework.http.MediaType; 14 | import org.springframework.r2dbc.connection.init.ConnectionFactoryInitializer; 15 | import org.springframework.test.context.ActiveProfiles; 16 | import org.springframework.test.web.reactive.server.WebTestClient; 17 | import reactor.core.publisher.Flux; 18 | import reactor.core.publisher.Mono; 19 | 20 | import java.util.List; 21 | 22 | import static org.junit.jupiter.api.Assertions.assertEquals; 23 | import static org.mockito.ArgumentMatchers.*; 24 | import static org.mockito.Mockito.when; 25 | 26 | @ActiveProfiles("test") 27 | @WebFluxTest(controllers = EmployeeController.class) 28 | class EmployeeControllerTest { 29 | @Autowired 30 | private WebTestClient client; 31 | 32 | @MockBean 33 | private EmployeeRepository employeeRepository; 34 | @MockBean 35 | private EmployeeService service; 36 | 37 | @Test 38 | @DisplayName("GET /employees should return 1 Employee") 39 | void getEmployees_shouldReturnEmployees() { 40 | when(this.service.getEmployees(isNull(), isNull())).thenReturn(Flux.just(stubbedEmployee())); 41 | 42 | client.get() 43 | .uri("/employees") 44 | .exchange() 45 | .expectStatus().isOk() 46 | .expectBodyList(Employee.class).hasSize(1) 47 | .consumeWith(employees -> assertEquals(List.of(stubbedEmployee()), employees.getResponseBody())); 48 | } 49 | 50 | @Test 51 | @DisplayName("GET /employees?position=Software%20Developer should return 1 Employee") 52 | void getEmployeesByPosition_shouldReturnEmployee() { 53 | when(this.service.getEmployees(anyString(), isNull())).thenReturn(Flux.just(stubbedEmployee())); 54 | 55 | client.get() 56 | .uri("/employees?position=Software%20Developer") 57 | .exchange() 58 | .expectStatus().isOk() 59 | .expectBodyList(Employee.class).hasSize(1) 60 | .consumeWith(employees -> assertEquals(List.of(stubbedEmployee()), employees.getResponseBody())); 61 | } 62 | 63 | @Test 64 | @DisplayName("GET /employees?fullTime=true should return 1 Employee") 65 | void getEmployeesByFullTime_shouldReturnEmployee() { 66 | when(this.service.getEmployees(isNull(), anyBoolean())).thenReturn(Flux.just(stubbedEmployee())); 67 | 68 | client.get() 69 | .uri("/employees?fullTime=true") 70 | .exchange() 71 | .expectStatus().isOk() 72 | .expectBodyList(Employee.class).hasSize(1) 73 | .consumeWith(employees -> assertEquals(List.of(stubbedEmployee()), employees.getResponseBody())); 74 | } 75 | 76 | @Test 77 | @DisplayName("GET /employees?position=Software%20Developer&fullTime=true should return 1 Employee") 78 | void getEmployeesByPositionAndFullTime_shouldReturnEmployee() { 79 | when(this.service.getEmployees(anyString(), anyBoolean())).thenReturn(Flux.just(stubbedEmployee())); 80 | 81 | client.get() 82 | .uri("/employees?position=Software%20Developer&fullTime=true") 83 | .exchange() 84 | .expectStatus().isOk() 85 | .expectBodyList(Employee.class).hasSize(1) 86 | .consumeWith(employees -> assertEquals(List.of(stubbedEmployee()), employees.getResponseBody())); 87 | } 88 | 89 | @Test 90 | @DisplayName("GET /employees/1 should return an Employee") 91 | void getEmployee_shouldReturnEmployee() { 92 | when(this.service.getEmployee(anyLong())).thenReturn(Mono.just(stubbedEmployee())); 93 | 94 | client.get() 95 | .uri("/employees/1") 96 | .exchange() 97 | .expectStatus().isOk() 98 | .expectBody(Employee.class) 99 | .consumeWith(employee -> assertEquals(stubbedEmployee(), employee.getResponseBody())); 100 | } 101 | 102 | @Test 103 | @DisplayName("GET /employees/2 should return EmployeeNotFoundException") 104 | void getEmployee_shouldReturnEmployeeNotFound() { 105 | when(this.service.getEmployee(anyLong())).thenThrow(new EmployeeNotFoundException(2L)); 106 | 107 | client.get() 108 | .uri("/employees/2") 109 | .exchange() 110 | .expectStatus().isNotFound() 111 | .expectBody(String.class) 112 | .consumeWith(exception -> assertEquals("Employee not found. Id: 2", exception.getResponseBody())); 113 | } 114 | 115 | @Test 116 | @DisplayName("POST /employees should return an Employee") 117 | void createEmployee_shouldReturnEmployee() { 118 | Employee newEmployee = Employee.builder() 119 | .firstName("Bob") 120 | .lastName("Walker") 121 | .position("Dog Walker") 122 | .fullTime(false) 123 | .build(); 124 | 125 | when(this.service.createEmployee(any(CreateEmployeeRequest.class))).thenReturn(Mono.just(newEmployee)); 126 | 127 | client.post().uri("/employees") 128 | .contentType(MediaType.APPLICATION_JSON) 129 | .bodyValue(new CreateEmployeeRequest("Bob", "Walker", "Dog Walker", false)) 130 | .exchange() 131 | .expectStatus().isCreated() 132 | .expectBody(Employee.class) 133 | .consumeWith(employee -> assertEquals(newEmployee, employee.getResponseBody())); 134 | } 135 | 136 | @Test 137 | @DisplayName("PUT /employees/1 should return an Employee") 138 | void updateEmployee_shouldReturnEmployee() { 139 | when(this.service.updateEmployee(anyLong(), any(Employee.class))).thenReturn(Mono.just(stubbedEmployee())); 140 | 141 | client.put().uri("/employees/1") 142 | .contentType(MediaType.APPLICATION_JSON) 143 | .bodyValue(stubbedEmployee()) 144 | .exchange() 145 | .expectStatus().isOk() 146 | .expectBody(Employee.class) 147 | .consumeWith(employee -> assertEquals(stubbedEmployee(), employee.getResponseBody())); 148 | } 149 | 150 | @Test 151 | @DisplayName("PUT /employees/2 should return EmployeeNotFoundException") 152 | void updateEmployee_shouldReturnEmployeeNotFound() { 153 | 154 | when(this.service.updateEmployee(anyLong(), any(Employee.class))).thenThrow(new EmployeeNotFoundException(2L)); 155 | 156 | client.put().uri("/employees/2") 157 | .contentType(MediaType.APPLICATION_JSON) 158 | .bodyValue(stubbedEmployee()) 159 | .exchange() 160 | .expectStatus().isNotFound() 161 | .expectBody(String.class) 162 | .consumeWith(exception -> assertEquals("Employee not found. Id: 2", exception.getResponseBody())); 163 | } 164 | 165 | @Test 166 | @DisplayName("DELETE /employees/1 should return OK") 167 | void deleteEmployee_shouldReturnOK() { 168 | when(this.service.deleteEmployee(anyLong())).thenReturn(Mono.empty()); 169 | 170 | client.delete().uri("/employees/1") 171 | .exchange() 172 | .expectStatus().isOk(); 173 | } 174 | 175 | @Test 176 | @DisplayName("DELETE /employees/2 should return EmployeeNotFoundException") 177 | void deleteEmployee_shouldReturnEmployeeNotFound() { 178 | when(this.service.deleteEmployee(anyLong())).thenThrow(new EmployeeNotFoundException(2L)); 179 | 180 | client.delete().uri("/employees/2") 181 | .exchange() 182 | .expectStatus().isNotFound() 183 | .expectBody(String.class) 184 | .consumeWith(exception -> assertEquals("Employee not found. Id: 2", exception.getResponseBody())); 185 | } 186 | 187 | private Employee stubbedEmployee() { 188 | return Employee.builder() 189 | .id(1L) 190 | .firstName("Neil") 191 | .lastName("White") 192 | .position("Software Developer") 193 | .fullTime(true) 194 | .build(); 195 | } 196 | } -------------------------------------------------------------------------------- /src/test/java/ca/neilwhite/hrservice/repositories/DepartmentRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.repositories; 2 | 3 | import ca.neilwhite.hrservice.models.Department; 4 | import ca.neilwhite.hrservice.models.Employee; 5 | import org.junit.jupiter.api.DisplayName; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.params.ParameterizedTest; 8 | import org.junit.jupiter.params.provider.Arguments; 9 | import org.junit.jupiter.params.provider.MethodSource; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.context.SpringBootTest; 12 | import org.springframework.test.annotation.DirtiesContext; 13 | import org.testcontainers.junit.jupiter.Testcontainers; 14 | import reactor.test.StepVerifier; 15 | 16 | import java.util.List; 17 | import java.util.stream.Stream; 18 | 19 | import static org.junit.jupiter.api.Assertions.assertEquals; 20 | 21 | @SpringBootTest 22 | @Testcontainers 23 | class DepartmentRepositoryTest { 24 | @Autowired 25 | private DepartmentRepositoryImpl repository; 26 | 27 | @Test 28 | @DisplayName("findAll() should return 2 Departments") 29 | void findAll_shouldReturnDepartments() { 30 | this.repository.findAll() 31 | .as(StepVerifier::create) 32 | .expectNextCount(2) 33 | .expectComplete() 34 | .verify(); 35 | } 36 | 37 | @Test 38 | @DisplayName("findById(10) should return a Department") 39 | void findById_shouldReturnDepartment() { 40 | this.repository.findById(10) 41 | .as(StepVerifier::create) 42 | .consumeNextWith(department -> assertEquals(stubbedDevDepartment(), department)) 43 | .verifyComplete(); 44 | } 45 | 46 | @Test 47 | @DisplayName("findById(3) should not return a Department") 48 | void findById_shouldNotReturnDepartment() { 49 | this.repository.findById(3) 50 | .as(StepVerifier::create) 51 | .expectNextCount(0) 52 | .verifyComplete(); 53 | } 54 | 55 | @Test 56 | @DisplayName("findByName(\"HR\") should return a Department") 57 | void findByName_shouldReturnDepartment() { 58 | this.repository.findByName("HR") 59 | .as(StepVerifier::create) 60 | .consumeNextWith(department -> assertEquals(stubbedHRDepartment(), department)) 61 | .verifyComplete(); 62 | } 63 | 64 | @Test 65 | @DisplayName("findByName(\"Accounting\") should not return a Department") 66 | void findByName_shouldNotReturnDepartment() { 67 | this.repository.findByName("Accounting") 68 | .as(StepVerifier::create) 69 | .expectNextCount(0) 70 | .verifyComplete(); 71 | } 72 | 73 | @DirtiesContext 74 | @ParameterizedTest 75 | @MethodSource("newDepartmentProvider") 76 | @DisplayName("save(department) should return a Department") 77 | void save_shouldSaveDepartment(Department newDepartment) { 78 | this.repository.save(newDepartment) 79 | .flatMap(department -> this.repository.findById(department.getId())) 80 | .as(StepVerifier::create) 81 | .consumeNextWith(department -> assertEquals(newDepartment, department)) 82 | .verifyComplete(); 83 | } 84 | 85 | @DirtiesContext 86 | @ParameterizedTest 87 | @MethodSource("updatedDepartmentProvider") 88 | @DisplayName("save(department) should return an updated Department") 89 | void save_shouldUpdateDepartment(Department updatedDepartment) { 90 | this.repository.save(updatedDepartment) 91 | .flatMap(department -> this.repository.findById(department.getId())) 92 | .as(StepVerifier::create) 93 | .consumeNextWith(department -> assertEquals(updatedDepartment, department)) 94 | .verifyComplete(); 95 | } 96 | 97 | @Test 98 | @DirtiesContext 99 | @DisplayName("delete(department) should delete a Department") 100 | void delete_shouldDeleteDepartment() { 101 | this.repository.delete(stubbedDevDepartment()) 102 | .flatMap(__ -> this.repository.findById(stubbedDevDepartment().getId())) 103 | .as(StepVerifier::create) 104 | .expectNextCount(0) 105 | .verifyComplete(); 106 | } 107 | 108 | private static Stream newDepartmentProvider() { 109 | Department newDepartmentNameOnly = Department.builder() 110 | .name("Accounting") 111 | .build(); 112 | 113 | Department newDepartmentWithManager = Department.builder() 114 | .name("Accounting") 115 | .manager(stubbedDevDepartment().getManager().get()) 116 | .build(); 117 | 118 | Department newDepartmentWithEmployees = Department.builder() 119 | .name("Accounting") 120 | .employees(stubbedDevDepartment().getEmployees()) 121 | .build(); 122 | 123 | Department newDepartmentWithManagerAndEmployees = Department.builder() 124 | .name("Accounting") 125 | .manager(stubbedDevDepartment().getManager().get()) 126 | .employees(stubbedDevDepartment().getEmployees()) 127 | .build(); 128 | 129 | return Stream.of( 130 | Arguments.of(newDepartmentNameOnly), 131 | Arguments.of(newDepartmentWithManager), 132 | Arguments.of(newDepartmentWithEmployees), 133 | Arguments.of(newDepartmentWithManagerAndEmployees) 134 | ); 135 | } 136 | 137 | private static Stream updatedDepartmentProvider() { 138 | Department nameUpdatedDepartment = stubbedDevDepartment(); 139 | nameUpdatedDepartment.setName("Software Engineering"); 140 | 141 | Department managerUpdatedDepartment = stubbedDevDepartment(); 142 | managerUpdatedDepartment.setManager(stubbedHRDepartment().getManager().get()); 143 | 144 | Department employeesUpdatedDepartment = stubbedDevDepartment(); 145 | employeesUpdatedDepartment.setEmployees(stubbedHRDepartment().getEmployees()); 146 | 147 | Department removeManagerAndEmployees = stubbedDevDepartment(); 148 | removeManagerAndEmployees.setManager(null); 149 | removeManagerAndEmployees.setEmployees(List.of()); 150 | 151 | return Stream.of( 152 | Arguments.of(nameUpdatedDepartment), 153 | Arguments.of(managerUpdatedDepartment), 154 | Arguments.of(employeesUpdatedDepartment), 155 | Arguments.of(removeManagerAndEmployees) 156 | ); 157 | } 158 | 159 | private static Department stubbedDevDepartment() { 160 | return Department.builder() 161 | .id(10L) 162 | .name("Software Development") 163 | .manager(Employee.builder() 164 | .id(10L) 165 | .firstName("Bob") 166 | .lastName("Steeves") 167 | .position("Director of Software Development") 168 | .fullTime(true) 169 | .build()) 170 | .employees(List.of( 171 | Employee.builder() 172 | .id(11L) 173 | .firstName("Neil") 174 | .lastName("White") 175 | .position("Software Developer") 176 | .fullTime(true) 177 | .build(), 178 | Employee.builder() 179 | .id(12L) 180 | .firstName("Joanna") 181 | .lastName("Bernier") 182 | .position("Software Tester") 183 | .fullTime(false) 184 | .build())) 185 | .build(); 186 | } 187 | 188 | private static Department stubbedHRDepartment() { 189 | return Department.builder() 190 | .id(20L) 191 | .name("HR") 192 | .manager(Employee.builder() 193 | .id(13L) 194 | .firstName("Cathy") 195 | .lastName("Ouellette") 196 | .position("Director of Human Resources") 197 | .fullTime(true) 198 | .build()) 199 | .employees(List.of( 200 | Employee.builder() 201 | .id(14L) 202 | .firstName("Alysha") 203 | .lastName("Rogers") 204 | .position("Intraday Analyst") 205 | .fullTime(true) 206 | .build())) 207 | .build(); 208 | } 209 | } -------------------------------------------------------------------------------- /src/main/java/ca/neilwhite/hrservice/repositories/DepartmentRepositoryImpl.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.repositories; 2 | 3 | import ca.neilwhite.hrservice.models.Department; 4 | import ca.neilwhite.hrservice.models.Employee; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.r2dbc.core.DatabaseClient; 7 | import org.springframework.stereotype.Component; 8 | import org.springframework.transaction.annotation.Transactional; 9 | import reactor.core.publisher.Flux; 10 | import reactor.core.publisher.Mono; 11 | 12 | import java.util.List; 13 | 14 | @Component 15 | @RequiredArgsConstructor 16 | public class DepartmentRepositoryImpl implements DepartmentRepository { 17 | private final EmployeeRepository employeeRepository; 18 | private final DatabaseClient client; 19 | private static final String SELECT_QUERY = """ 20 | SELECT d.id d_id, d.name d_name, m.id m_id, m.first_name m_firstName, m.last_name m_lastName, 21 | m.position m_position, m.is_full_time m_isFullTime, e.id e_id, e.first_name e_firstName, 22 | e.last_name e_lastName, e.position e_position, e.is_full_time e_isFullTime 23 | FROM departments d 24 | LEFT JOIN department_managers dm ON dm.department_id = d.id 25 | LEFT JOIN employees m ON m.id = dm.employee_id 26 | LEFT JOIN department_employees de ON de.department_id = d.id 27 | LEFT JOIN employees e ON e.id = de.employee_id 28 | """; 29 | 30 | /** 31 | * Returns all Departments. 32 | * 33 | * @return Flux of {@link Department} 34 | */ 35 | @Override 36 | public Flux findAll() { 37 | String query = String.format("%s ORDER BY d.id", SELECT_QUERY); 38 | 39 | return client.sql(query) 40 | .fetch() 41 | .all() 42 | .bufferUntilChanged(result -> result.get("d_id")) 43 | .flatMap(Department::fromRows); 44 | } 45 | 46 | /** 47 | * Returns a Department by ID. 48 | * 49 | * @param id Department ID 50 | * @return Mono of {@link Department} 51 | */ 52 | @Override 53 | public Mono findById(long id) { 54 | String query = String.format("%s WHERE d.id = :id", SELECT_QUERY); 55 | 56 | return client.sql(query) 57 | .bind("id", id) 58 | .fetch() 59 | .all() 60 | .bufferUntilChanged(result -> result.get("d_id")) 61 | .flatMap(Department::fromRows) 62 | .singleOrEmpty(); 63 | } 64 | 65 | /** 66 | * Returns a Department by name. 67 | * 68 | * @param name Department Name 69 | * @return Mono of {@link Department} 70 | */ 71 | @Override 72 | public Mono findByName(String name) { 73 | String query = String.format("%s WHERE d.name = :name", SELECT_QUERY); 74 | 75 | return client.sql(query) 76 | .bind("name", name) 77 | .fetch() 78 | .all() 79 | .bufferUntilChanged(result -> result.get("d_id")) 80 | .flatMap(Department::fromRows) 81 | .singleOrEmpty(); 82 | } 83 | 84 | /** 85 | * Saves and returns a Department. 86 | * 87 | * @param department {@link Department} 88 | * @return Mono of {@link Department} 89 | */ 90 | @Override 91 | @Transactional 92 | public Mono save(Department department) { 93 | return this.saveDepartment(department) 94 | .flatMap(this::saveManager) 95 | .flatMap(this::saveEmployees) 96 | .flatMap(this::deleteDepartmentManager) 97 | .flatMap(this::saveDepartmentManager) 98 | .flatMap(this::deleteDepartmentEmployees) 99 | .flatMap(this::saveDepartmentEmployees); 100 | } 101 | 102 | /** 103 | * Deletes a Department. 104 | * 105 | * @param department {@link Department} 106 | * @return Mono of {@link Void} 107 | */ 108 | @Override 109 | @Transactional 110 | public Mono delete(Department department) { 111 | return this.deleteDepartmentManager(department) 112 | .flatMap(this::deleteDepartmentEmployees) 113 | .flatMap(this::deleteDepartment) 114 | .then(); 115 | } 116 | 117 | /** 118 | * Saves a Department. 119 | * 120 | * @param department {@link Department} 121 | * @return Mono of {@link Department} 122 | */ 123 | private Mono saveDepartment(Department department) { 124 | if (department.getId() == null) { 125 | return client.sql("INSERT INTO departments(name) VALUES(:name)") 126 | .bind("name", department.getName()) 127 | .filter((statement, executeFunction) -> statement.returnGeneratedValues("id").execute()) 128 | .fetch().first() 129 | .doOnNext(result -> department.setId(Long.parseLong(result.get("id").toString()))) 130 | .thenReturn(department); 131 | } else { 132 | return this.client.sql("UPDATE departments SET name = :name WHERE id = :id") 133 | .bind("name", department.getName()) 134 | .bind("id", department.getId()) 135 | .fetch().first() 136 | .thenReturn(department); 137 | } 138 | } 139 | 140 | /** 141 | * Saves a Department Manager. 142 | * 143 | * @param department {@link Department} 144 | * @return Mono of {@link Department} 145 | */ 146 | private Mono saveManager(Department department) { 147 | return Mono.justOrEmpty(department.getManager()) 148 | .flatMap(employeeRepository::save) 149 | .doOnNext(department::setManager) 150 | .thenReturn(department); 151 | } 152 | 153 | /** 154 | * Saves Department Employees. 155 | * 156 | * @param department {@link Department} 157 | * @return Mono of {@link Department} 158 | */ 159 | private Mono saveEmployees(Department department) { 160 | return Flux.fromIterable(department.getEmployees()) 161 | .flatMap(this.employeeRepository::save) 162 | .collectList() 163 | .doOnNext(department::setEmployees) 164 | .thenReturn(department); 165 | } 166 | 167 | /** 168 | * Saves the relationship between Department and Manager. 169 | * 170 | * @param department {@link Department} 171 | * @return Mono of {@link Department} 172 | */ 173 | private Mono saveDepartmentManager(Department department) { 174 | String query = "INSERT INTO department_managers(department_id, employee_id) VALUES (:id, :empId)"; 175 | 176 | return Mono.justOrEmpty(department.getManager()) 177 | .flatMap(manager -> client.sql(query) 178 | .bind("id", department.getId()) 179 | .bind("empId", manager.getId()) 180 | .fetch().rowsUpdated()) 181 | .thenReturn(department); 182 | } 183 | 184 | /** 185 | * Saves the relationship between Department and Employees. 186 | * 187 | * @param department {@link Department} 188 | * @return Mono of {@link Department} 189 | */ 190 | private Mono saveDepartmentEmployees(Department department) { 191 | String query = "INSERT INTO department_employees(department_id, employee_id) VALUES (:id, :empId)"; 192 | 193 | return Flux.fromIterable(department.getEmployees()) 194 | .flatMap(employee -> client.sql(query) 195 | .bind("id", department.getId()) 196 | .bind("empId", employee.getId()) 197 | .fetch().rowsUpdated()) 198 | .collectList() 199 | .thenReturn(department); 200 | } 201 | 202 | /** 203 | * Deletes a Department. 204 | * 205 | * @param department {@link Department} 206 | * @return Mono of {@link Void} 207 | */ 208 | private Mono deleteDepartment(Department department) { 209 | return client.sql("DELETE FROM departments WHERE id = :id") 210 | .bind("id", department.getId()) 211 | .fetch().first() 212 | .then(); 213 | } 214 | 215 | /** 216 | * Deletes the relationship between Department and Manager. 217 | * 218 | * @param department {@link Department} 219 | * @return Mono of {@link Department} 220 | */ 221 | private Mono deleteDepartmentManager(Department department) { 222 | String query = "DELETE FROM department_managers WHERE department_id = :departmentId OR employee_id = :managerId"; 223 | 224 | return Mono.just(department) 225 | .flatMap(dep -> client.sql(query) 226 | .bind("departmentId", dep.getId()) 227 | .bindNull("managerId", Long.class) 228 | .bind("managerId", dep.getManager().orElseGet(() -> Employee.builder().id(0L).build()).getId()) 229 | .fetch().rowsUpdated()) 230 | .thenReturn(department); 231 | } 232 | 233 | /** 234 | * Deletes the relationship between Department and Employees. 235 | * 236 | * @param department {@link Department} 237 | * @return Mono of {@link Department} 238 | */ 239 | private Mono deleteDepartmentEmployees(Department department) { 240 | String query = "DELETE FROM department_employees WHERE department_id = :id OR employee_id in (:ids)"; 241 | 242 | List employeeIds = department.getEmployees().stream().map(Employee::getId).toList(); 243 | 244 | return Mono.just(department) 245 | .flatMap(dep -> client.sql(query) 246 | .bind("id", department.getId()) 247 | .bind("ids", employeeIds.isEmpty() ? List.of(0) : employeeIds) 248 | .fetch().rowsUpdated()) 249 | .thenReturn(department); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/test/java/ca/neilwhite/hrservice/controllers/DepartmentControllerTest.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.controllers; 2 | 3 | import ca.neilwhite.hrservice.exceptions.DepartmentAlreadyExistsException; 4 | import ca.neilwhite.hrservice.exceptions.DepartmentNotFoundException; 5 | import ca.neilwhite.hrservice.models.Department; 6 | import ca.neilwhite.hrservice.models.Employee; 7 | import ca.neilwhite.hrservice.models.requests.CreateDepartmentRequest; 8 | import ca.neilwhite.hrservice.repositories.DepartmentRepositoryImpl; 9 | import ca.neilwhite.hrservice.repositories.EmployeeRepository; 10 | import ca.neilwhite.hrservice.services.DepartmentService; 11 | import io.r2dbc.spi.ConnectionFactory; 12 | import org.junit.jupiter.api.DisplayName; 13 | import org.junit.jupiter.api.Test; 14 | import org.springframework.beans.factory.annotation.Autowired; 15 | import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; 16 | import org.springframework.boot.test.mock.mockito.MockBean; 17 | import org.springframework.http.MediaType; 18 | import org.springframework.r2dbc.connection.init.ConnectionFactoryInitializer; 19 | import org.springframework.test.context.ActiveProfiles; 20 | import org.springframework.test.web.reactive.server.WebTestClient; 21 | import reactor.core.publisher.Flux; 22 | import reactor.core.publisher.Mono; 23 | 24 | import java.util.List; 25 | 26 | import static org.junit.jupiter.api.Assertions.assertEquals; 27 | import static org.mockito.ArgumentMatchers.*; 28 | import static org.mockito.Mockito.when; 29 | 30 | @ActiveProfiles("test") 31 | @WebFluxTest(controllers = DepartmentController.class) 32 | class DepartmentControllerTest { 33 | 34 | @Autowired 35 | private WebTestClient client; 36 | 37 | @MockBean 38 | private DepartmentRepositoryImpl repository; 39 | @MockBean 40 | private EmployeeRepository employeeRepository; 41 | @MockBean 42 | private DepartmentService service; 43 | 44 | @Test 45 | @DisplayName("GET /departments should return 1 Department") 46 | void getDepartments_shouldReturnDepartments() { 47 | when(this.service.getDepartments()).thenReturn(Flux.just(stubbedDevDepartment())); 48 | 49 | client.get() 50 | .uri("/departments") 51 | .exchange() 52 | .expectStatus().isOk() 53 | .expectBodyList(Department.class).hasSize(1) 54 | .consumeWith(departments -> assertEquals(List.of(stubbedDevDepartment()), departments.getResponseBody())); 55 | } 56 | 57 | @Test 58 | @DisplayName("GET /departments/1 should return a Department") 59 | void getDepartment_shouldReturnDepartment() { 60 | when(this.service.getDepartment(anyLong())).thenReturn(Mono.just(stubbedDevDepartment())); 61 | 62 | client.get() 63 | .uri("/departments/1") 64 | .exchange() 65 | .expectStatus().isOk() 66 | .expectBody(Department.class) 67 | .consumeWith(department -> assertEquals(stubbedDevDepartment(), department.getResponseBody())); 68 | } 69 | 70 | @Test 71 | @DisplayName("GET /departments/10 should return DepartmentNotFoundException") 72 | void getDepartment_shouldReturnDepartmentNotFound() { 73 | when(this.service.getDepartment(anyLong())).thenThrow(new DepartmentNotFoundException(10L)); 74 | 75 | client.get() 76 | .uri("/departments/10") 77 | .exchange() 78 | .expectStatus().isNotFound() 79 | .expectBody(String.class) 80 | .consumeWith(exception -> assertEquals("Department not found. Id: 10", exception.getResponseBody())); 81 | } 82 | 83 | @Test 84 | @DisplayName("GET /departments/1/employees should return 2 Employees") 85 | void getDepartmentEmployees_shouldReturnEmployees() { 86 | when(this.service.getDepartmentEmployees(anyLong(), isNull())) 87 | .thenReturn(Flux.fromIterable(stubbedDevDepartment().getEmployees())); 88 | 89 | client.get() 90 | .uri("/departments/1/employees") 91 | .exchange() 92 | .expectStatus().isOk() 93 | .expectBodyList(Employee.class).hasSize(2) 94 | .consumeWith(employees -> assertEquals(stubbedDevDepartment().getEmployees(), employees.getResponseBody())); 95 | } 96 | 97 | @Test 98 | @DisplayName("GET /departments/1/employees?fullTime=true should return 1 Employees") 99 | void getFullTimeDepartmentEmployees_shouldReturnEmployees() { 100 | when(this.service.getDepartmentEmployees(anyLong(), anyBoolean())) 101 | .thenReturn(Flux.just(stubbedDevDepartment().getEmployees().get(0))); 102 | 103 | client.get() 104 | .uri("/departments/1/employees?fullTime=true") 105 | .exchange() 106 | .expectStatus().isOk() 107 | .expectBodyList(Employee.class).hasSize(1) 108 | .consumeWith(employees -> assertEquals(List.of(stubbedDevDepartment().getEmployees().get(0)), employees.getResponseBody())); 109 | } 110 | 111 | @Test 112 | @DisplayName("POST /departments should return a Department") 113 | void createDepartment_shouldReturnDepartment() { 114 | Department accounting = Department.builder().name("Accounting").build(); 115 | 116 | when(this.service.createDepartment(any(CreateDepartmentRequest.class))).thenReturn(Mono.just(accounting)); 117 | 118 | client.post().uri("/departments") 119 | .contentType(MediaType.APPLICATION_JSON) 120 | .bodyValue(new CreateDepartmentRequest("Accounting")) 121 | .exchange() 122 | .expectStatus().isCreated() 123 | .expectBody(Department.class) 124 | .consumeWith(department -> assertEquals(accounting, department.getResponseBody())); 125 | } 126 | 127 | @Test 128 | @DisplayName("POST /departments should return DepartmentAlreadyExistsException") 129 | void createDepartment_shouldReturnDepartmentAlreadyExists() { 130 | when(this.service.createDepartment(any(CreateDepartmentRequest.class))).thenThrow(new DepartmentAlreadyExistsException("Accounting")); 131 | 132 | client.post().uri("/departments") 133 | .contentType(MediaType.APPLICATION_JSON) 134 | .bodyValue(new CreateDepartmentRequest("Accounting")) 135 | .exchange() 136 | .expectStatus().isBadRequest() 137 | .expectBody(String.class) 138 | .consumeWith(exception -> assertEquals("Department with name \"Accounting\" already exists.", exception.getResponseBody())); 139 | } 140 | 141 | @Test 142 | @DisplayName("POST /departments should return HTTP 400 - validation issue") 143 | void createDepartment_shouldReturnBadRequest() { 144 | client.post().uri("/departments") 145 | .contentType(MediaType.APPLICATION_JSON) 146 | .bodyValue(new CreateDepartmentRequest("")) 147 | .exchange() 148 | .expectStatus().isBadRequest(); 149 | } 150 | 151 | @Test 152 | @DisplayName("PUT /departments/1 should return a Department") 153 | void updateDepartment_shouldReturnDepartment() { 154 | when(this.service.updateDepartment(anyLong(), any(Department.class))).thenReturn(Mono.just(stubbedDevDepartment())); 155 | 156 | client.put().uri("/departments/1") 157 | .contentType(MediaType.APPLICATION_JSON) 158 | .bodyValue(stubbedDevDepartment()) 159 | .exchange() 160 | .expectStatus().isOk() 161 | .expectBody(Department.class) 162 | .consumeWith(department -> assertEquals(stubbedDevDepartment(), department.getResponseBody())); 163 | } 164 | 165 | @Test 166 | @DisplayName("PUT /departments/10 should return DepartmentNotFoundException") 167 | void updateDepartment_shouldReturnDepartmentNotFound() { 168 | when(this.service.updateDepartment(anyLong(), any(Department.class))).thenThrow(new DepartmentNotFoundException(10L)); 169 | 170 | client.put().uri("/departments/10") 171 | .contentType(MediaType.APPLICATION_JSON) 172 | .bodyValue(stubbedDevDepartment()) 173 | .exchange() 174 | .expectStatus().isNotFound() 175 | .expectBody(String.class) 176 | .consumeWith(exception -> assertEquals("Department not found. Id: 10", exception.getResponseBody())); 177 | } 178 | 179 | @Test 180 | @DisplayName("DELETE /departments/1 should return OK") 181 | void deleteDepartment_shouldReturnOK() { 182 | when(this.service.deleteDepartment(anyLong())).thenReturn(Mono.empty()); 183 | 184 | client.delete().uri("/departments/1") 185 | .exchange() 186 | .expectStatus().isOk(); 187 | } 188 | 189 | @Test 190 | @DisplayName("DELETE /departments/10 should return DepartmentNotFoundException") 191 | void deleteDepartment_shouldReturnDepartmentNotFound() { 192 | when(this.service.deleteDepartment(anyLong())).thenThrow(new DepartmentNotFoundException(10L)); 193 | 194 | client.delete().uri("/departments/10") 195 | .exchange() 196 | .expectStatus().isNotFound() 197 | .expectBody(String.class) 198 | .consumeWith(exception -> assertEquals("Department not found. Id: 10", exception.getResponseBody())); 199 | } 200 | 201 | private Department stubbedDevDepartment() { 202 | return Department.builder() 203 | .id(1L) 204 | .name("Software Development") 205 | .manager(Employee.builder() 206 | .id(1L) 207 | .firstName("Bob") 208 | .lastName("Steeves") 209 | .position("Director of Software Development") 210 | .fullTime(true) 211 | .build()) 212 | .employees(List.of( 213 | Employee.builder() 214 | .id(2L) 215 | .firstName("Neil") 216 | .lastName("White") 217 | .position("Software Developer") 218 | .fullTime(true) 219 | .build(), 220 | Employee.builder() 221 | .id(3L) 222 | .firstName("Joanna") 223 | .lastName("Bernier") 224 | .position("Software Tester") 225 | .fullTime(false) 226 | .build())) 227 | .build(); 228 | } 229 | } -------------------------------------------------------------------------------- /src/test/java/ca/neilwhite/hrservice/services/DepartmentServiceTest.java: -------------------------------------------------------------------------------- 1 | package ca.neilwhite.hrservice.services; 2 | 3 | import ca.neilwhite.hrservice.exceptions.DepartmentAlreadyExistsException; 4 | import ca.neilwhite.hrservice.exceptions.DepartmentNotFoundException; 5 | import ca.neilwhite.hrservice.models.Department; 6 | import ca.neilwhite.hrservice.models.Employee; 7 | import ca.neilwhite.hrservice.models.requests.CreateDepartmentRequest; 8 | import ca.neilwhite.hrservice.repositories.DepartmentRepository; 9 | import org.junit.jupiter.api.DisplayName; 10 | import org.junit.jupiter.api.Test; 11 | import org.junit.jupiter.api.extension.ExtendWith; 12 | import org.mockito.InjectMocks; 13 | import org.mockito.Mock; 14 | import org.mockito.junit.jupiter.MockitoExtension; 15 | import reactor.core.publisher.Flux; 16 | import reactor.core.publisher.Mono; 17 | import reactor.test.StepVerifier; 18 | 19 | import java.util.List; 20 | 21 | import static org.junit.jupiter.api.Assertions.assertEquals; 22 | import static org.mockito.ArgumentMatchers.*; 23 | import static org.mockito.Mockito.when; 24 | 25 | @ExtendWith(MockitoExtension.class) 26 | class DepartmentServiceTest { 27 | @Mock 28 | private DepartmentRepository repository; 29 | @InjectMocks 30 | private DepartmentService service; 31 | 32 | @Test 33 | @DisplayName("getDepartments() should return 2 Departments") 34 | void getDepartments_shouldReturnDepartments() { 35 | when(this.repository.findAll()).thenReturn(Flux.fromIterable(List.of(stubbedDevDepartment(), stubbedHRDepartment()))); 36 | 37 | this.service.getDepartments() 38 | .as(StepVerifier::create) 39 | .expectNextCount(2) 40 | .verifyComplete(); 41 | } 42 | 43 | @Test 44 | @DisplayName("getDepartment(1) should return a Department") 45 | void getDepartment_shouldReturnDepartment() { 46 | when(this.repository.findById(anyLong())).thenReturn(Mono.just(stubbedDevDepartment())); 47 | 48 | this.service.getDepartment(1L) 49 | .as(StepVerifier::create) 50 | .consumeNextWith(department -> assertEquals(stubbedDevDepartment(), department)) 51 | .verifyComplete(); 52 | } 53 | 54 | @Test 55 | @DisplayName("getDepartment(3) should throw DepartmentNotFoundException") 56 | void getDepartment_shouldThrowDepartmentNotFound() { 57 | when(this.repository.findById(anyLong())).thenReturn(Mono.empty()); 58 | 59 | this.service.getDepartment(3L) 60 | .as(StepVerifier::create) 61 | .expectError(DepartmentNotFoundException.class) 62 | .verify(); 63 | } 64 | 65 | @Test 66 | @DisplayName("getDepartmentEmployees(1, null) should return 2 Employees") 67 | void getDepartmentEmployees_shouldReturnEmployees() { 68 | when(this.repository.findById(anyLong())).thenReturn(Mono.just(stubbedDevDepartment())); 69 | 70 | this.service.getDepartmentEmployees(1L, null) 71 | .as(StepVerifier::create) 72 | .expectNextCount(2) 73 | .verifyComplete(); 74 | } 75 | 76 | @Test 77 | @DisplayName("getDepartmentEmployees(1, true) should return 1 Employees") 78 | void getDepartmentEmployees_FullTime_shouldReturnEmployees() { 79 | when(this.repository.findById(anyLong())).thenReturn(Mono.just(stubbedDevDepartment())); 80 | 81 | this.service.getDepartmentEmployees(1L, true) 82 | .as(StepVerifier::create) 83 | .expectNextCount(1) 84 | .verifyComplete(); 85 | } 86 | 87 | @Test 88 | @DisplayName("getDepartmentEmployees(1, false) should return 1 Employees") 89 | void getDepartmentEmployees_PartTime_shouldReturnEmployees() { 90 | when(this.repository.findById(anyLong())).thenReturn(Mono.just(stubbedDevDepartment())); 91 | 92 | this.service.getDepartmentEmployees(1L, false) 93 | .as(StepVerifier::create) 94 | .expectNextCount(1) 95 | .verifyComplete(); 96 | } 97 | 98 | @Test 99 | @DisplayName("getDepartmentEmployees(3, null) should throw DepartmentNotFoundException") 100 | void getDepartmentEmployees_shouldThrowDepartmentNotFound() { 101 | when(this.repository.findById(anyLong())).thenReturn(Mono.empty()); 102 | 103 | this.service.getDepartmentEmployees(3L, null) 104 | .as(StepVerifier::create) 105 | .expectError(DepartmentNotFoundException.class) 106 | .verify(); 107 | } 108 | 109 | @Test 110 | @DisplayName("createDepartment(request) should return a Department") 111 | void createDepartment_shouldReturnDepartment() { 112 | Department accounting = Department.builder() 113 | .id(3L) 114 | .name("Accounting") 115 | .build(); 116 | 117 | when(this.repository.findByName(anyString())).thenReturn(Mono.empty()); 118 | when(this.repository.save(any(Department.class))).thenReturn(Mono.just(accounting)); 119 | 120 | this.service.createDepartment(new CreateDepartmentRequest("Accounting")) 121 | .as(StepVerifier::create) 122 | .consumeNextWith(department -> assertEquals(accounting, department)) 123 | .verifyComplete(); 124 | } 125 | 126 | @Test 127 | @DisplayName("createDepartment(request) should throw DepartmentAlreadyExistsException") 128 | void createDepartment_shouldThrowDepartmentAlreadyExists() { 129 | Department accounting = Department.builder() 130 | .id(3L) 131 | .name("Accounting") 132 | .build(); 133 | 134 | when(this.repository.findByName(anyString())).thenReturn(Mono.just(accounting)); 135 | 136 | this.service.createDepartment(new CreateDepartmentRequest("Accounting")) 137 | .as(StepVerifier::create) 138 | .expectError(DepartmentAlreadyExistsException.class) 139 | .verify(); 140 | } 141 | 142 | @Test 143 | @DisplayName("updateDepartment(1, department) should return an updated Department") 144 | void updateDepartment_shouldReturnDepartment() { 145 | Department updatedDevDepartment = stubbedDevDepartment(); 146 | 147 | Employee manager = Employee.builder() 148 | .id(6L) 149 | .firstName("Sally") 150 | .lastName("Smith") 151 | .position("Director of Software Development") 152 | .fullTime(true) 153 | .build(); 154 | 155 | updatedDevDepartment.setManager(manager); 156 | 157 | when(this.repository.findById(anyLong())).thenReturn(Mono.just(stubbedDevDepartment())); 158 | when(this.repository.save(any(Department.class))).thenReturn(Mono.just(updatedDevDepartment)); 159 | 160 | this.service.updateDepartment(1L, updatedDevDepartment) 161 | .as(StepVerifier::create) 162 | .consumeNextWith(department -> assertEquals(updatedDevDepartment, department)) 163 | .verifyComplete(); 164 | } 165 | 166 | @Test 167 | @DisplayName("updateDepartment(3, department) should throw DepartmentNotFoundException") 168 | void updateDepartment_shouldThrowDepartmentNotFound() { 169 | when(this.repository.findById(anyLong())).thenReturn(Mono.empty()); 170 | 171 | this.service.updateDepartment(3L, stubbedDevDepartment()) 172 | .as(StepVerifier::create) 173 | .expectError(DepartmentNotFoundException.class) 174 | .verify(); 175 | } 176 | 177 | @Test 178 | @DisplayName("deleteDepartment(1) should complete") 179 | void deleteDepartment_shouldDeleteDepartment() { 180 | when(this.repository.findById(anyLong())).thenReturn(Mono.just(stubbedDevDepartment())); 181 | when(this.repository.delete(any(Department.class))).thenReturn(Mono.empty()); 182 | 183 | this.service.deleteDepartment(stubbedDevDepartment().getId()) 184 | .as(StepVerifier::create) 185 | .verifyComplete(); 186 | } 187 | 188 | @Test 189 | @DisplayName("deleteDepartment(3) should throw DepartmentNotFound") 190 | void deleteDepartment_shouldThrowDepartmentNotFound() { 191 | when(this.repository.findById(anyLong())).thenReturn(Mono.empty()); 192 | 193 | this.service.deleteDepartment(3L) 194 | .as(StepVerifier::create) 195 | .expectError(DepartmentNotFoundException.class) 196 | .verify(); 197 | } 198 | 199 | private Department stubbedDevDepartment() { 200 | return Department.builder() 201 | .id(1L) 202 | .name("Software Development") 203 | .manager(Employee.builder() 204 | .id(1L) 205 | .firstName("Bob") 206 | .lastName("Steeves") 207 | .position("Director of Software Development") 208 | .fullTime(true) 209 | .build()) 210 | .employees(List.of( 211 | Employee.builder() 212 | .id(2L) 213 | .firstName("Neil") 214 | .lastName("White") 215 | .position("Software Developer") 216 | .fullTime(true) 217 | .build(), 218 | Employee.builder() 219 | .id(3L) 220 | .firstName("Joanna") 221 | .lastName("Bernier") 222 | .position("Software Tester") 223 | .fullTime(false) 224 | .build())) 225 | .build(); 226 | } 227 | 228 | private Department stubbedHRDepartment() { 229 | return Department.builder() 230 | .id(2L) 231 | .name("HR") 232 | .manager(Employee.builder() 233 | .id(4L) 234 | .firstName("Cathy") 235 | .lastName("Ouellette") 236 | .position("Director of Human Resources") 237 | .fullTime(true) 238 | .build()) 239 | .employees(List.of( 240 | Employee.builder() 241 | .id(5L) 242 | .firstName("Alysha") 243 | .lastName("Rogers") 244 | .position("Intraday Analyst") 245 | .fullTime(true) 246 | .build())) 247 | .build(); 248 | } 249 | } -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # https://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Maven Start Up Batch script 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # M2_HOME - location of maven2's installed home dir 31 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 32 | # e.g. to debug Maven itself, use 33 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 34 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 35 | # ---------------------------------------------------------------------------- 36 | 37 | if [ -z "$MAVEN_SKIP_RC" ] ; then 38 | 39 | if [ -f /usr/local/etc/mavenrc ] ; then 40 | . /usr/local/etc/mavenrc 41 | fi 42 | 43 | if [ -f /etc/mavenrc ] ; then 44 | . /etc/mavenrc 45 | fi 46 | 47 | if [ -f "$HOME/.mavenrc" ] ; then 48 | . "$HOME/.mavenrc" 49 | fi 50 | 51 | fi 52 | 53 | # OS specific support. $var _must_ be set to either true or false. 54 | cygwin=false; 55 | darwin=false; 56 | mingw=false 57 | case "`uname`" in 58 | CYGWIN*) cygwin=true ;; 59 | MINGW*) mingw=true;; 60 | Darwin*) darwin=true 61 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 62 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 63 | if [ -z "$JAVA_HOME" ]; then 64 | if [ -x "/usr/libexec/java_home" ]; then 65 | export JAVA_HOME="`/usr/libexec/java_home`" 66 | else 67 | export JAVA_HOME="/Library/Java/Home" 68 | fi 69 | fi 70 | ;; 71 | esac 72 | 73 | if [ -z "$JAVA_HOME" ] ; then 74 | if [ -r /etc/gentoo-release ] ; then 75 | JAVA_HOME=`java-config --jre-home` 76 | fi 77 | fi 78 | 79 | if [ -z "$M2_HOME" ] ; then 80 | ## resolve links - $0 may be a link to maven's home 81 | PRG="$0" 82 | 83 | # need this for relative symlinks 84 | while [ -h "$PRG" ] ; do 85 | ls=`ls -ld "$PRG"` 86 | link=`expr "$ls" : '.*-> \(.*\)$'` 87 | if expr "$link" : '/.*' > /dev/null; then 88 | PRG="$link" 89 | else 90 | PRG="`dirname "$PRG"`/$link" 91 | fi 92 | done 93 | 94 | saveddir=`pwd` 95 | 96 | M2_HOME=`dirname "$PRG"`/.. 97 | 98 | # make it fully qualified 99 | M2_HOME=`cd "$M2_HOME" && pwd` 100 | 101 | cd "$saveddir" 102 | # echo Using m2 at $M2_HOME 103 | fi 104 | 105 | # For Cygwin, ensure paths are in UNIX format before anything is touched 106 | if $cygwin ; then 107 | [ -n "$M2_HOME" ] && 108 | M2_HOME=`cygpath --unix "$M2_HOME"` 109 | [ -n "$JAVA_HOME" ] && 110 | JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 111 | [ -n "$CLASSPATH" ] && 112 | CLASSPATH=`cygpath --path --unix "$CLASSPATH"` 113 | fi 114 | 115 | # For Mingw, ensure paths are in UNIX format before anything is touched 116 | if $mingw ; then 117 | [ -n "$M2_HOME" ] && 118 | M2_HOME="`(cd "$M2_HOME"; pwd)`" 119 | [ -n "$JAVA_HOME" ] && 120 | JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" 121 | fi 122 | 123 | if [ -z "$JAVA_HOME" ]; then 124 | javaExecutable="`which javac`" 125 | if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then 126 | # readlink(1) is not available as standard on Solaris 10. 127 | readLink=`which readlink` 128 | if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then 129 | if $darwin ; then 130 | javaHome="`dirname \"$javaExecutable\"`" 131 | javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" 132 | else 133 | javaExecutable="`readlink -f \"$javaExecutable\"`" 134 | fi 135 | javaHome="`dirname \"$javaExecutable\"`" 136 | javaHome=`expr "$javaHome" : '\(.*\)/bin'` 137 | JAVA_HOME="$javaHome" 138 | export JAVA_HOME 139 | fi 140 | fi 141 | fi 142 | 143 | if [ -z "$JAVACMD" ] ; then 144 | if [ -n "$JAVA_HOME" ] ; then 145 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 146 | # IBM's JDK on AIX uses strange locations for the executables 147 | JAVACMD="$JAVA_HOME/jre/sh/java" 148 | else 149 | JAVACMD="$JAVA_HOME/bin/java" 150 | fi 151 | else 152 | JAVACMD="`\\unset -f command; \\command -v java`" 153 | fi 154 | fi 155 | 156 | if [ ! -x "$JAVACMD" ] ; then 157 | echo "Error: JAVA_HOME is not defined correctly." >&2 158 | echo " We cannot execute $JAVACMD" >&2 159 | exit 1 160 | fi 161 | 162 | if [ -z "$JAVA_HOME" ] ; then 163 | echo "Warning: JAVA_HOME environment variable is not set." 164 | fi 165 | 166 | CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher 167 | 168 | # traverses directory structure from process work directory to filesystem root 169 | # first directory with .mvn subdirectory is considered project base directory 170 | find_maven_basedir() { 171 | 172 | if [ -z "$1" ] 173 | then 174 | echo "Path not specified to find_maven_basedir" 175 | return 1 176 | fi 177 | 178 | basedir="$1" 179 | wdir="$1" 180 | while [ "$wdir" != '/' ] ; do 181 | if [ -d "$wdir"/.mvn ] ; then 182 | basedir=$wdir 183 | break 184 | fi 185 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 186 | if [ -d "${wdir}" ]; then 187 | wdir=`cd "$wdir/.."; pwd` 188 | fi 189 | # end of workaround 190 | done 191 | echo "${basedir}" 192 | } 193 | 194 | # concatenates all lines of a file 195 | concat_lines() { 196 | if [ -f "$1" ]; then 197 | echo "$(tr -s '\n' ' ' < "$1")" 198 | fi 199 | } 200 | 201 | BASE_DIR=`find_maven_basedir "$(pwd)"` 202 | if [ -z "$BASE_DIR" ]; then 203 | exit 1; 204 | fi 205 | 206 | ########################################################################################## 207 | # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 208 | # This allows using the maven wrapper in projects that prohibit checking in binary data. 209 | ########################################################################################## 210 | if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then 211 | if [ "$MVNW_VERBOSE" = true ]; then 212 | echo "Found .mvn/wrapper/maven-wrapper.jar" 213 | fi 214 | else 215 | if [ "$MVNW_VERBOSE" = true ]; then 216 | echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." 217 | fi 218 | if [ -n "$MVNW_REPOURL" ]; then 219 | jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 220 | else 221 | jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 222 | fi 223 | while IFS="=" read key value; do 224 | case "$key" in (wrapperUrl) jarUrl="$value"; break ;; 225 | esac 226 | done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" 227 | if [ "$MVNW_VERBOSE" = true ]; then 228 | echo "Downloading from: $jarUrl" 229 | fi 230 | wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" 231 | if $cygwin; then 232 | wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` 233 | fi 234 | 235 | if command -v wget > /dev/null; then 236 | if [ "$MVNW_VERBOSE" = true ]; then 237 | echo "Found wget ... using wget" 238 | fi 239 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 240 | wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 241 | else 242 | wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 243 | fi 244 | elif command -v curl > /dev/null; then 245 | if [ "$MVNW_VERBOSE" = true ]; then 246 | echo "Found curl ... using curl" 247 | fi 248 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 249 | curl -o "$wrapperJarPath" "$jarUrl" -f 250 | else 251 | curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f 252 | fi 253 | 254 | else 255 | if [ "$MVNW_VERBOSE" = true ]; then 256 | echo "Falling back to using Java to download" 257 | fi 258 | javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" 259 | # For Cygwin, switch paths to Windows format before running javac 260 | if $cygwin; then 261 | javaClass=`cygpath --path --windows "$javaClass"` 262 | fi 263 | if [ -e "$javaClass" ]; then 264 | if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 265 | if [ "$MVNW_VERBOSE" = true ]; then 266 | echo " - Compiling MavenWrapperDownloader.java ..." 267 | fi 268 | # Compiling the Java class 269 | ("$JAVA_HOME/bin/javac" "$javaClass") 270 | fi 271 | if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 272 | # Running the downloader 273 | if [ "$MVNW_VERBOSE" = true ]; then 274 | echo " - Running MavenWrapperDownloader.java ..." 275 | fi 276 | ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") 277 | fi 278 | fi 279 | fi 280 | fi 281 | ########################################################################################## 282 | # End of extension 283 | ########################################################################################## 284 | 285 | export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} 286 | if [ "$MVNW_VERBOSE" = true ]; then 287 | echo $MAVEN_PROJECTBASEDIR 288 | fi 289 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 290 | 291 | # For Cygwin, switch paths to Windows format before running java 292 | if $cygwin; then 293 | [ -n "$M2_HOME" ] && 294 | M2_HOME=`cygpath --path --windows "$M2_HOME"` 295 | [ -n "$JAVA_HOME" ] && 296 | JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` 297 | [ -n "$CLASSPATH" ] && 298 | CLASSPATH=`cygpath --path --windows "$CLASSPATH"` 299 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 300 | MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` 301 | fi 302 | 303 | # Provide a "standardized" way to retrieve the CLI args that will 304 | # work with both Windows and non-Windows executions. 305 | MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" 306 | export MAVEN_CMD_LINE_ARGS 307 | 308 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 309 | 310 | exec "$JAVACMD" \ 311 | $MAVEN_OPTS \ 312 | $MAVEN_DEBUG_OPTS \ 313 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 314 | "-Dmaven.home=${M2_HOME}" \ 315 | "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 316 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 317 | --------------------------------------------------------------------------------