12 Commits

Author SHA1 Message Date
szsa
ed95090be8 Tries to fix problem with clearing of the db (not solved yet) 2022-06-06 21:01:20 +02:00
szsa
f3dcc948b0 Creates new test of creating the new team (need to be fixed) 2022-05-19 23:10:11 +02:00
szsa
946387ae89 introduces router test in first integration test 2022-05-18 23:10:09 +02:00
szsa
340d3b2a8a introduces spring profiles and prepares IT tests, cleans up pom.xml 2022-05-17 22:47:41 +02:00
szsa
d4bd59141b introduces proper setting for testcontainers (TBD - profile) 2022-05-17 20:16:34 +02:00
szsa
cbeac683d8 creates draft of datamongotest solution for IT test 2022-05-16 15:27:03 +02:00
szsa
45665a77d8 creates basic setup for testcontainer (not working yet) 2022-05-16 13:37:42 +02:00
szsa
db5b518a8a creates new package structure for integration tests 2022-05-16 08:18:17 +02:00
Szymon Sawicki
fdfea5223b Testing (#1)
This pull request introduces unit tests of TeamService and makes some small changes in service
2022-05-10 21:39:27 +02:00
szsa
550564ee2b Creates zip archive for simple launch of the application and updates README.md 2022-04-23 21:19:59 +02:00
Szymon Sawicki
bdc99b6f84 creates README.md 2022-04-23 20:53:00 +02:00
szsa
ea81148464 Makes some small refactorings and adds postman collection 2022-04-23 12:48:39 +02:00
25 changed files with 952 additions and 47 deletions

14
README.md Normal file
View File

@@ -0,0 +1,14 @@
## Reactive time sheet app
It's simple REST API using Spring Webflux, MongoDB and layered architecture using basics of domain driven design approach. Exact description of this project you can find on my blog - [link](https://szymonsawicki.net/?p=60)
## How to launch the application ?
Installed Docker and Postman will be needed. At the beginning ou must download reactive-timesheet-app.zip archive from the root directory in the repository. In hte terminal enter command:
`docker compose up -d --build`
In the zip archive you can find postman collection which have set of request which are prepared for tests. Don't forget to update id in the header :)

22
pom.xml
View File

@@ -16,6 +16,17 @@
<properties>
<java.version>17</java.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.17.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -47,6 +58,17 @@
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.17.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mongodb</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

165
postman_collection.json Normal file
View File

@@ -0,0 +1,165 @@
{
"info": {
"_postman_id": "809992e0-63cb-4d98-ae6b-30e259a35348",
"name": "Reactive Timesheet App",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Get user by id",
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"method": "GET",
"header": [],
"body": {
"mode": "raw",
"raw": "{\r\n \"username\" : \"John Test\",\r\n \"password\" : \"223eded3\",\r\n \"passwordConfirmation\" : \"223eded3\",\r\n \"role\" : \"LEAD\"\r\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "http://localhost:8080/users/id/62378d381507b40858b58695",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"users",
"id",
"62378d381507b40858b58695"
]
}
},
"response": []
},
{
"name": "Get user by name",
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"method": "GET",
"header": [],
"body": {
"mode": "raw",
"raw": "{\r\n \"username\" : \"John Test\",\r\n \"password\" : \"223eded3\",\r\n \"passwordConfirmation\" : \"223eded3\",\r\n \"role\" : \"LEAD\"\r\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "http://localhost:8080/users/id/62378d381507b40858b58695",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"users",
"id",
"62378d381507b40858b58695"
]
}
},
"response": []
},
{
"name": "Create user",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\r\n \"username\" : \"John Test\",\r\n \"password\" : \"223eded3\",\r\n \"passwordConfirmation\" : \"223eded3\",\r\n \"role\" : \"LEAD\"\r\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "http://localhost:8080/users/",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"users",
""
]
}
},
"response": []
},
{
"name": "Create team",
"request": {
"method": "GET",
"header": []
},
"response": []
},
{
"name": "delete team",
"request": {
"method": "GET",
"header": []
},
"response": []
},
{
"name": "delete user",
"request": {
"method": "GET",
"header": []
},
"response": []
},
{
"name": "creating of the new time entry",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\r\n \"date\" : \"1999-12-12\",\r\n \"timeFrom\" : \" \",\r\n \"timeTo\" : \" \",\r\n \"user\" : {\r\n \"username\": \"Arnold Test\",\r\n \"password\": \"223eded3\",\r\n \"role\": \"DEVELOPER\"\r\n },\r\n \"category\": \"DEVELOPMENT\" \r\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "http://localhost:8080/team-entries/",
"protocol": "http",
"host": [
"localhost"
],
"port": "8080",
"path": [
"team-entries",
""
]
}
},
"response": []
},
{
"name": "get team by id",
"request": {
"method": "GET",
"header": []
},
"response": []
}
]
}

BIN
reactive-timesheet-app.zip Normal file

Binary file not shown.

View File

@@ -10,6 +10,7 @@ import net.szymonsawicki.reactivetimesheetapp.domain.team.dto.GetTeamDto;
import net.szymonsawicki.reactivetimesheetapp.domain.team.repository.TeamRepository;
import net.szymonsawicki.reactivetimesheetapp.domain.user.repository.UserRepository;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Service
@@ -20,38 +21,38 @@ public class TeamService {
private final TeamRepository teamRepository;
private final UserRepository userRepository;
public Flux<GetTeamDto> findAllTeams() {
return teamRepository.findAll()
.flatMap(team -> Flux.just(team.toGetTeamDto()));
}
public Mono<GetTeamDto> findById(String teamId) {
return teamRepository.findById(teamId)
.map(Team::toGetTeamDto)
.switchIfEmpty(Mono.error(new TeamServiceException("id doesn't exist")));
.switchIfEmpty(Mono.error(new TeamServiceException("Team with given id doesn't exist")));
}
public Mono<GetTeamDto> findByName(String name) {
return teamRepository.findByName(name)
.map(Team::toGetTeamDto)
.switchIfEmpty(Mono.error(new TeamServiceException("Username doesn't exist")));
.switchIfEmpty(Mono.error(new TeamServiceException("Team with given name doesn't exist")));
}
public Mono<GetTeamDto> addTeam(Mono<CreateTeamDto> createTeamDtoMono) {
return createTeamDtoMono
.flatMap(createTeamDto -> teamRepository.findByName(createTeamDto.name())
.map(team -> {
log.error("Team with name " + createTeamDto.name() + " already exists");
return team.toGetTeamDto();
})
.switchIfEmpty(createTeamWithMembers(createTeamDto))
);
.doOnEach(team -> log.error("Team with name " + createTeamDto.name() + " already exists"))
.map(Team::toGetTeamDto)
.switchIfEmpty(Mono.defer(() -> createTeamWithMembers(createTeamDto))));
}
private Mono<GetTeamDto> createTeamWithMembers(CreateTeamDto createTeamDto) {
var teamToInsert = createTeamDto.toTeam();
// at first team is inserted into db and all its member are updated with the new teamId
return teamRepository
.save(teamToInsert)
.save(createTeamDto.toTeam())
.flatMap(insertedTeam -> {
var membersToInsert = createTeamDto
.members()
@@ -64,12 +65,9 @@ public class TeamService {
return userRepository
.saveAll(membersToInsert)
.collectList()
.flatMap(insertedUsers -> {
var teamToInsertWithMembers = insertedTeam.withMembers(insertedUsers);
return teamRepository
.save(teamToInsertWithMembers)
.map(Team::toGetTeamDto);
});
.flatMap(insertedUsers -> teamRepository
.save(insertedTeam.withMembers(insertedUsers))
.map(Team::toGetTeamDto));
});
}
@@ -94,5 +92,4 @@ public class TeamService {
})
.switchIfEmpty(Mono.error(new TeamServiceException("cannot find team to delete")));
}
}

View File

@@ -33,7 +33,7 @@ public class TimeEntryService {
return userRepository
.findById(timeEntryToCheck.user().id())
.hasElement()
.flatMap(isUserPresent -> Boolean.TRUE.equals(isUserPresent)
.flatMap(isUserPresent -> isUserPresent
?
findCollisions(timeEntryToCheck)
:
@@ -41,6 +41,9 @@ public class TimeEntryService {
}
private Mono<CreateTimeEntryDto> findCollisions(CreateTimeEntryDto timeEntryToCheck) {
// TODO proper implementation of collision check (doesn't work at the moment). Create isAvailable() method in TimeEntry domain class
return timeEntryRepository.findAllByUser(timeEntryToCheck.user().toUser())
.filter(entry -> !TimeEntryUtils.toTimeFrom.apply(entry).isAfter(timeEntryToCheck.timeTo())
&& !TimeEntryUtils.toTimeTo.apply(entry).isBefore(timeEntryToCheck.timeFrom()))

View File

@@ -38,7 +38,7 @@ public class UserService {
.flatMap(createUserDto -> userRepository
.findByUsername(createUserDto.username())
.hasElement()
.flatMap(isUserPresent -> Boolean.TRUE.equals(isUserPresent)
.flatMap(isUserPresent -> isUserPresent
?
Mono.error(new UserServiceException("user with username " + createUserDto.username() + " already exists"))
:

View File

@@ -13,5 +13,7 @@ public interface CrudRepository<T, ID> {
Mono<T> save(T t);
Mono<T> delete(ID id);
Mono<Void> deleteAll();
}

View File

@@ -32,7 +32,7 @@ public class Team {
return TeamEntity.builder()
.id(id)
.name(name)
.members(members)
.members(members == null ? null : members.stream().map(User::toEntity).toList())
.build();
}

View File

@@ -8,5 +8,6 @@ import java.util.function.Function;
public interface TeamUtils {
Function<Team, String> toId = team -> team.id;
Function<Team, String> toName = team -> team.name;
Function<Team, List<User>> toMembers = team -> team.members;
}

View File

@@ -45,6 +45,6 @@ public class User {
id,
username,
password,
role);
role, teamId);
}
}

View File

@@ -3,13 +3,14 @@ package net.szymonsawicki.reactivetimesheetapp.domain.user.dto;
import net.szymonsawicki.reactivetimesheetapp.domain.user.User;
import net.szymonsawicki.reactivetimesheetapp.domain.user.type.Role;
public record GetUserDto(String id, String username, String password, Role role) {
public record GetUserDto(String id, String username, String password, Role role, String teamId) {
public User toUser() {
return User.builder()
.id(id)
.username(username)
.password(password)
.role(role)
.teamId(teamId)
.build();
}
}

View File

@@ -22,13 +22,13 @@ public class TeamEntity {
String id;
String name;
List<User> members;
List<UserEntity> members;
public Team toTeam() {
return Team.builder()
.id(id)
.name(name)
.members(members)
.members(members.stream().map(UserEntity::toUser).toList())
.build();
}
}

View File

@@ -3,7 +3,6 @@ package net.szymonsawicki.reactivetimesheetapp.infrastructure.persistence.reposi
import lombok.RequiredArgsConstructor;
import net.szymonsawicki.reactivetimesheetapp.domain.team.Team;
import net.szymonsawicki.reactivetimesheetapp.domain.team.repository.TeamRepository;
import net.szymonsawicki.reactivetimesheetapp.domain.user.User;
import net.szymonsawicki.reactivetimesheetapp.infrastructure.persistence.dao.TeamDao;
import net.szymonsawicki.reactivetimesheetapp.infrastructure.persistence.exception.PersistenceException;
import org.springframework.stereotype.Repository;
@@ -51,6 +50,11 @@ public class TeamsRepositoryImpl implements TeamRepository {
.switchIfEmpty(Mono.error(new PersistenceException("cannot find team to delete")));
}
@Override
public Mono<Void> deleteAll() {
return teamDao.deleteAll();
}
public Mono<Team> findByName(String name) {
return teamDao.findByName(name)
.flatMap(teamEntity -> Mono.just(teamEntity.toTeam()));

View File

@@ -52,6 +52,11 @@ public class TimeEntryRepositoryImpl implements TimeEntryRepository {
.switchIfEmpty(Mono.error(new PersistenceException("cannot find team to delete")));
}
@Override
public Mono<Void> deleteAll() {
return timeEntryDao.deleteAll();
}
@Override
public Flux<TimeEntry> findAllByUser(User user) {
return timeEntryDao.findAllByUser(user.toEntity())

View File

@@ -74,4 +74,9 @@ public class UserRepositoryImpl implements UserRepository {
return userDao
.deleteAll(users.stream().map(User::toEntity).toList());
}
@Override
public Mono<Void> deleteAll() {
return userDao.deleteAll();
}
}

View File

@@ -28,13 +28,14 @@ public class Routing {
RouterFunctions.route(GET("/{name}").and(accept(MediaType.APPLICATION_JSON)), teamHandlers::findByName)
.andRoute(GET("/id/{id}").and(accept(MediaType.APPLICATION_JSON)), teamHandlers::findById)
.andRoute(POST("/").and(accept(MediaType.APPLICATION_JSON)), teamHandlers::addTeam)
.andRoute(DELETE("/{id}").and(accept(MediaType.APPLICATION_JSON)), teamHandlers::deleteTeam))
.andNest(path("/users"),
RouterFunctions.route(GET("/id/{id}").and(accept(MediaType.APPLICATION_JSON)), userHandlers::findById)
.andRoute(GET("/{username}").and(accept(MediaType.APPLICATION_JSON)), userHandlers::findByUsername)
.andRoute(POST("/").and(accept(MediaType.APPLICATION_JSON)), userHandlers::createUser)
.andRoute(DELETE("/{id}").and(accept(MediaType.APPLICATION_JSON)), userHandlers::deleteUser))
.andNest(path("/time-entries"),
RouterFunctions.route(POST("/").and(accept(MediaType.APPLICATION_JSON)), timeEntryHandlers::addTimeEntry));
.andRoute(DELETE("/{id}").and(accept(MediaType.APPLICATION_JSON)), teamHandlers::deleteTeam)
.andNest(path("/reset"), RouterFunctions.route(GET("/reset").and(accept(MediaType.APPLICATION_JSON)), teamHandlers::findByName))
.andNest(path("/users"),
RouterFunctions.route(GET("/id/{id}").and(accept(MediaType.APPLICATION_JSON)), userHandlers::findById)
.andRoute(GET("/{username}").and(accept(MediaType.APPLICATION_JSON)), userHandlers::findByUsername)
.andRoute(POST("/").and(accept(MediaType.APPLICATION_JSON)), userHandlers::createUser)
.andRoute(DELETE("/{id}").and(accept(MediaType.APPLICATION_JSON)), userHandlers::deleteUser))
.andNest(path("/time-entries"),
RouterFunctions.route(POST("/").and(accept(MediaType.APPLICATION_JSON)), timeEntryHandlers::addTimeEntry)));
}
}

View File

@@ -1,13 +1,18 @@
package net.szymonsawicki.reactivetimesheetapp.web.handler;
import jdk.jfr.ContentType;
import lombok.RequiredArgsConstructor;
import net.szymonsawicki.reactivetimesheetapp.application.service.TeamService;
import net.szymonsawicki.reactivetimesheetapp.domain.team.dto.CreateTeamDto;
import net.szymonsawicki.reactivetimesheetapp.domain.team.dto.GetTeamDto;
import net.szymonsawicki.reactivetimesheetapp.web.config.GlobalRoutingHandler;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Component
@@ -15,6 +20,13 @@ import reactor.core.publisher.Mono;
public class TeamHandlers {
private final TeamService teamService;
public Mono<ServerResponse> findAllTeams(ServerRequest serverRequest) {
return ServerResponse
.status(HttpStatus.OK)
.contentType(MediaType.APPLICATION_JSON)
.body(teamService.findAllTeams(),GetTeamDto.class);
}
public Mono<ServerResponse> findById(ServerRequest serverRequest) {
var teamId = serverRequest.pathVariable("id");
return GlobalRoutingHandler.doRequest(teamService.findById(teamId), HttpStatus.OK);

View File

@@ -0,0 +1,8 @@
spring:
application:
name: reactive-timesheet-app
data:
mongodb:
database: db_1
host: localhost
port: 27017

View File

@@ -0,0 +1,3 @@
spring:
application:
name: reactive-timesheet-app

View File

@@ -1,13 +0,0 @@
package net.szymonsawicki.reactivetimesheetapp;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class ReactiveTimesheetAppApplicationTests {
@Test
void contextLoads() {
}
}

View File

@@ -0,0 +1,192 @@
package net.szymonsawicki.reactivetimesheetapp.application.service;
import net.szymonsawicki.reactivetimesheetapp.application.service.exception.TeamServiceException;
import net.szymonsawicki.reactivetimesheetapp.domain.team.Team;
import net.szymonsawicki.reactivetimesheetapp.domain.team.TeamUtils;
import net.szymonsawicki.reactivetimesheetapp.domain.team.dto.CreateTeamDto;
import net.szymonsawicki.reactivetimesheetapp.domain.team.dto.GetTeamDto;
import net.szymonsawicki.reactivetimesheetapp.domain.team.repository.TeamRepository;
import net.szymonsawicki.reactivetimesheetapp.domain.user.User;
import net.szymonsawicki.reactivetimesheetapp.domain.user.UserUtils;
import net.szymonsawicki.reactivetimesheetapp.domain.user.dto.GetUserDto;
import net.szymonsawicki.reactivetimesheetapp.domain.user.repository.UserRepository;
import net.szymonsawicki.reactivetimesheetapp.domain.user.type.Role;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.BodyInserters;
import org.testcontainers.containers.MongoDBContainer;
import org.testcontainers.junit.jupiter.Testcontainers;
import reactor.test.StepVerifier;
import java.util.List;
@SpringBootTest
@Testcontainers
@AutoConfigureWebTestClient
@ActiveProfiles("test")
public class TeamServiceIT {
/* @ClassRule
private static final MongoDBContainer MONGO_DB_CONTAINER = TimesheetAppMongoDbContainer.getInstance();
*/
private static final MongoDBContainer MONGO_DB_CONTAINER =
new MongoDBContainer("mongo:4.2.8");
@Autowired
private TeamRepository teamRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private WebTestClient webClient;
@BeforeEach
void clearDb() {
userRepository.deleteAll();
teamRepository.deleteAll();
}
@BeforeAll
static void setUpAll() {
MONGO_DB_CONTAINER.start();
}
@AfterAll
static void tearDownAll() {
if (!MONGO_DB_CONTAINER.isShouldBeReused()) {
MONGO_DB_CONTAINER.stop();
}
}
@Test
void shouldReturnTeamOnGetById() {
String teamId = "2r872394r578";
String teamName = "Some team";
String username = "testsusrname";
Role role = Role.DEVELOPER;
var member = User.builder()
.username(username)
.password("some password")
.teamId(teamId)
.build();
var team = Team.builder()
.name(teamName)
.members(List.of(member))
.build();
var insertedTeam = teamRepository.save(team);
var insertedTeamId = TeamUtils.toId.apply(insertedTeam.block());
webClient.get().uri("/teams/id/{id}", insertedTeamId)
.header(HttpHeaders.ACCEPT, "application/json")
.exchange()
.expectStatus().isOk()
.expectBodyList(GetTeamDto.class);
StepVerifier.create(teamRepository.findById(insertedTeamId))
.expectNextMatches(t -> TeamUtils.toMembers.apply(t).size() == 1)
.verifyComplete();
}
@Test
void shouldReturnErrorOnGetByIdWhenNotExist() {
webClient.get().uri("/teams/id/{id}", "test123")
.header(HttpHeaders.ACCEPT, "application/json")
.exchange()
.expectStatus().is5xxServerError()
.expectBodyList(TeamServiceException.class);
}
@Test
@DirtiesContext
void shouldCreateTeamWithTwoMembersOnCreate() {
String teamName = "Some test team2";
String username1 = "testsusrname1";
String username2 = "testsusrname2";
String password1 = "kongamafonga";
String password2 = "kupaladupa.";
Role role = Role.DEVELOPER;
var member1 = new GetUserDto(null, username1, password1, role, null);
// var member2 = new GetUserDto(null, username2, password2, role, null);
var createTeamDto = new CreateTeamDto(teamName, List.of(member1));
webClient.post().uri("/teams/")
.header(HttpHeaders.ACCEPT, "application/json")
.body(BodyInserters.fromValue(createTeamDto))
.exchange()
.expectStatus().isCreated()
.expectBodyList(GetTeamDto.class);
StepVerifier.create(teamRepository.findByName(teamName))
.assertNext(t -> {
Assertions.assertThat(TeamUtils.toName.apply(t)).isEqualTo(teamName);
Assertions.assertThat(TeamUtils.toMembers.apply(t).size()).isEqualTo(2);
Assertions.assertThat(TeamUtils.toMembers.apply(t).stream().map(UserUtils.toUsername).toList())
.containsAll(List.of(username1, username2));
})
.verifyComplete();
var insertedTeamId = TeamUtils.toId.apply(teamRepository.findByName(teamName).block());
StepVerifier.create(userRepository.findByUsername(username1))
.expectNextMatches(user -> UserUtils.toTeamId.apply(user).equals(insertedTeamId))
.verifyComplete();
StepVerifier.create(userRepository.findByUsername(username2))
.expectNextMatches(user -> UserUtils.toTeamId.apply(user).equals(insertedTeamId))
.verifyComplete();
}
@Test
void shouldReturnExistingTeamOnCreateWhenNameTaken() {
String teamName = "Some test team2";
String username1 = "testsusrname1";
String password1 = "sdcvdfvbgdf";
Role role = Role.DEVELOPER;
var team = Team.builder()
.name(teamName)
.members(null)
.build();
var insertedTeam = teamRepository.save(team);
var member1 = new GetUserDto(null, username1, password1, role, null);
var createTeamDto = new CreateTeamDto(teamName, List.of(member1));
webClient.post().uri("/teams/")
.header(HttpHeaders.ACCEPT, "application/json")
.body(BodyInserters.fromValue(createTeamDto))
.exchange()
.expectStatus().is2xxSuccessful()
.expectBodyList(TeamServiceException.class);
StepVerifier.create(teamRepository.findByName(teamName))
.assertNext(t -> {
Assertions.assertThat(TeamUtils.toName.apply(t)).isEqualTo(teamName);
// Assertions.assertThat(TeamUtils.toMembers.apply(t).size()).isZero();
})
.verifyComplete();
}
}

View File

@@ -0,0 +1,452 @@
package net.szymonsawicki.reactivetimesheetapp.application.service;
import net.szymonsawicki.reactivetimesheetapp.application.service.exception.TeamServiceException;
import net.szymonsawicki.reactivetimesheetapp.domain.team.Team;
import net.szymonsawicki.reactivetimesheetapp.domain.team.TeamUtils;
import net.szymonsawicki.reactivetimesheetapp.domain.team.dto.CreateTeamDto;
import net.szymonsawicki.reactivetimesheetapp.domain.team.dto.GetTeamDto;
import net.szymonsawicki.reactivetimesheetapp.domain.team.repository.TeamRepository;
import net.szymonsawicki.reactivetimesheetapp.domain.user.User;
import net.szymonsawicki.reactivetimesheetapp.domain.user.UserUtils;
import net.szymonsawicki.reactivetimesheetapp.domain.user.dto.GetUserDto;
import net.szymonsawicki.reactivetimesheetapp.domain.user.repository.UserRepository;
import net.szymonsawicki.reactivetimesheetapp.domain.user.type.Role;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InOrder;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.util.List;
@ExtendWith(SpringExtension.class)
public class TeamServiceTest {
@TestConfiguration
public static class TeamServiceTestConfiguration {
@MockBean
public TeamRepository teamRepository;
@MockBean
public UserRepository userRepository;
@Bean
public TeamService teamService() {
return new TeamService(teamRepository, userRepository);
}
}
@Autowired
public TeamRepository teamRepository;
@Autowired
public UserRepository userRepository;
@Autowired
public TeamService teamService;
@Captor
public ArgumentCaptor<List<User>> usersCaptor;
@Test
public void shouldReturnThreeTeamsOnFindAll() {
String userId1 = "21344r23r34";
String userId2 = "21344r23r34";
String teamId1 = "2r872394r578";
String teamId2 = "2r872394r578";
String teamName1 = "Some team";
String teamName2 = "Some team";
String username1 = "testsusrname";
String username2 = "testsusrname2";
var member1 = new GetUserDto(
userId1,
username1,
"some password",
null, teamId1);
var member2 = new GetUserDto(
userId2,
username2,
"some password",
null, teamId2);
var expectedTeam1 = new GetTeamDto(
teamId1,
teamName1,
List.of(member1));
var expectedTeam2 = new GetTeamDto(
teamId2,
teamName2,
List.of(member2));
var memberFromDb1 = User.builder()
.id(userId1)
.username(username1)
.password("some password")
.teamId(teamId1)
.build();
var memberFromDb2 = User.builder()
.id(userId2)
.username(username2)
.password("some password")
.teamId(teamId2)
.build();
var teamFromDb1 = Team.builder()
.id(teamId1)
.name(teamName1)
.members(List.of(memberFromDb1))
.build();
var teamFromDb2 = Team.builder()
.id(teamId2)
.name(teamName2)
.members(List.of(memberFromDb2))
.build();
Mockito.when(teamRepository.findAll())
.thenReturn(Flux.just(teamFromDb1, teamFromDb2));
StepVerifier
.create(teamService.findAllTeams())
.expectNext(expectedTeam1)
.expectNext(expectedTeam2)
.verifyComplete();
}
@Test
public void shouldReturnTeamWithOneMemberOnGetById() {
String userId = "21344r23r34";
String teamId = "2r872394r578";
String teamName = "Some team";
String username = "testsusrname";
Role role = Role.DEVELOPER;
var member = User.builder()
.username(username)
.password("some password")
.teamId(teamId)
.build();
var teamEntityMono = Mono.just(Team.builder()
.id(teamId)
.name(teamName)
.members(List.of(member))
.build());
Mockito.when(teamRepository.findById(Mockito.anyString()))
.thenReturn(teamEntityMono);
StepVerifier
.create(teamService.findById(userId))
.assertNext(team -> {
Assertions.assertThat(team.name().equals(teamName));
Assertions.assertThat(team.members()).hasSize(1);
Assertions.assertThat(team.members().get(0).username()).isEqualTo(username);
})
.verifyComplete();
}
@Test
public void shouldReturnErrorOnGetByIdWhenTeamMissing() {
Mockito.when(teamRepository.findById(Mockito.anyString()))
.thenReturn(Mono.empty());
StepVerifier
.create(teamService.findById("some id"))
.expectErrorMatches(error -> error instanceof TeamServiceException && error.getMessage().equals("Team with given id doesn't exist"))
.verify();
}
@Test
public void shouldFindTeamByName() {
String userId = "21344r23r34";
String teamId = "2r872394r578";
String teamName = "Some team";
String username = "testsusrname";
var member = User.builder()
.id(userId)
.username(username)
.password("some password")
.teamId(teamId)
.build();
var expectedMemberDto = new GetUserDto(
userId,
username,
"some password",
null, teamId);
var teamMono = Mono.just(Team.builder()
.id(teamId)
.name(teamName)
.members(List.of(member))
.build());
Mockito.when(teamRepository.findByName(Mockito.anyString()))
.thenReturn(teamMono);
StepVerifier
.create(teamService.findByName(teamName))
.expectNextMatches(resultingTeam -> resultingTeam.name().equals(teamName))
.verifyComplete();
}
@Test
public void shouldReturnErrorOnGetTeamByNameWhenMissing() {
Mockito.when(teamRepository.findByName(Mockito.anyString()))
.thenReturn(Mono.empty());
StepVerifier
.create(teamService.findByName("some name"))
.expectErrorMatches(error -> error instanceof TeamServiceException && error.getMessage().equals("Team with given name doesn't exist"))
.verify();
}
@Test
public void shouldCreateNewTeamWithMembers() {
String userId1 = "21344r23r34";
String userId2 = "21344r23r34";
String teamId = "2r872394r578";
String teamName = "Some team";
String username1 = "testsusrname";
String username2 = "testsusrname2";
ArgumentCaptor<Team> saveTeamCaptor = ArgumentCaptor.forClass(Team.class);
var member1 = new GetUserDto(
userId1,
username1,
"some password",
null, null);
var member2 = new GetUserDto(
userId2,
username2,
"some password",
null, null);
var savedMember1 = User.builder()
.id(userId1)
.username(username1)
.password("some password")
.teamId(teamId)
.build();
var savedMember2 = User.builder()
.id(userId2)
.username(username2)
.password("some password")
.teamId(teamId)
.build();
var teamToCreateMono = Mono.just(new CreateTeamDto(
teamName
, List.of(member1, member2)));
var createdTeamMono = Mono.just(Team.builder()
.id(teamId)
.name(teamName)
.members(List.of(savedMember1, savedMember2))
.build());
Mockito.when(teamRepository.findByName(Mockito.anyString()))
.thenReturn(Mono.empty());
Mockito.when(teamRepository.save(saveTeamCaptor.capture()))
.thenReturn(createdTeamMono);
Mockito.when(userRepository.saveAll(usersCaptor.capture()))
.thenReturn(Flux.just(savedMember1, savedMember2));
StepVerifier
.create(teamService.addTeam(teamToCreateMono))
.assertNext(team -> {
Assertions.assertThat(team.name()).isEqualTo(teamName);
Assertions.assertThat(team.members()).hasSize(2);
Assertions.assertThat(team.members().stream().map(GetUserDto::username).toList()).containsAll(List.of(username1, username2));
Assertions.assertThat(team.members().stream().filter(member -> !member.teamId().equals(teamId)).toList()).isEmpty();
// captor assertions
Assertions.assertThat(TeamUtils.toMembers.apply(saveTeamCaptor.getValue())).hasSize(2);
Assertions.assertThat(TeamUtils.toId.apply(saveTeamCaptor.getValue())).isEqualTo(teamId);
Assertions.assertThat(usersCaptor.getValue()).hasSize(2);
})
.verifyComplete();
InOrder inOrder = Mockito.inOrder(teamRepository, userRepository);
inOrder.verify(teamRepository, Mockito.times(1)).findByName(teamName);
inOrder.verify(teamRepository, Mockito.times(1)).save(Mockito.any(Team.class));
inOrder.verify(userRepository, Mockito.times(1)).saveAll(Mockito.any());
inOrder.verify(teamRepository, Mockito.times(1)).save(Mockito.any(Team.class));
Mockito.verifyNoMoreInteractions(teamRepository, userRepository);
}
@Test
public void shouldReturnExistingTeamWhenNameIsTakenOnAddTeam() {
String userId1 = "21344r23r34";
String userId2 = "tzuh";
String teamId = "2r872394r578";
String teamName = "Some team";
String username1 = "testsusrname";
String username2 = "testsusrname2";
var member1 = new GetUserDto(
userId1,
username1,
"some password",
null, null);
var member2 = new GetUserDto(
userId2,
username2,
"some password",
null, null);
var expectedMember1 = new GetUserDto(
userId1,
username1,
"some password",
null, teamId);
var expectedMember2 = new GetUserDto(
userId2,
username2,
"some password",
null, teamId);
var savedMember1 = User.builder()
.id(userId1)
.username(username1)
.password("some password")
.teamId(teamId)
.build();
var savedMember2 = User.builder()
.id(userId2)
.username(username2)
.password("some password")
.teamId(teamId)
.build();
var teamToCreateMono = Mono.just(new CreateTeamDto(
teamName
, List.of(member1, member2)));
var expectedTeamDto = new GetTeamDto(
teamId,
teamName
, List.of(expectedMember1, expectedMember2));
var existingTeamMono = Mono.just(Team.builder()
.id(teamId)
.name(teamName)
.members(List.of(savedMember1, savedMember2))
.build());
Mockito.when(teamRepository.findByName(Mockito.anyString()))
.thenReturn(existingTeamMono);
StepVerifier
.create(teamService.addTeam(teamToCreateMono))
.expectNext(expectedTeamDto)
.verifyComplete();
Mockito.verify(teamRepository, Mockito.never())
.save(Mockito.any());
Mockito.verify(userRepository, Mockito.never())
.saveAll(Mockito.any());
}
@Test
public void ShouldDeleteTeamOnDelete() {
String userId1 = "21344r23r34";
String userId2 = "21344r23r34";
String teamId = "2r872394r578";
String teamName = "Some team";
String username1 = "testsusrname";
String username2 = "testsusrname2";
var existingMember1 = User.builder()
.id(userId1)
.username(username1)
.password("some password")
.teamId(teamId)
.build();
var existingMember2 = User.builder()
.id(userId2)
.username(username2)
.password("some password")
.teamId(teamId)
.build();
var existingTeam = Team.builder()
.id(teamId)
.name(teamName)
.members(List.of(existingMember1, existingMember2))
.build();
Mockito.when(teamRepository.findById(Mockito.anyString()))
.thenReturn(Mono.just(existingTeam));
Mockito.when(userRepository.saveAll(usersCaptor.capture()))
.thenReturn(Flux.just(existingMember1, existingMember2));
Mockito.when(teamRepository.delete(Mockito.anyString()))
.thenReturn(Mono.empty());
StepVerifier
.create(teamService.deleteTeam(teamId))
.assertNext(team -> {
Assertions.assertThat(team.name()).isEqualTo(teamName);
Assertions.assertThat(team.members()).hasSize(2);
Assertions.assertThat(team.members().stream().map(GetUserDto::username).toList()).containsAll(List.of(username1, username2));
Assertions.assertThat(team.members().stream().filter(member -> !member.teamId().equals(teamId)).toList()).isEmpty();
// captor assertions
Assertions.assertThat(usersCaptor.getValue()).hasSize(2);
Assertions.assertThat(usersCaptor.getValue().stream().filter(user -> UserUtils.toTeamId.apply(user) != null).toList()).isEmpty();
})
.verifyComplete();
Mockito.verify(teamRepository, Mockito.times(1))
.delete(teamId);
}
@Test
public void shouldReturnErrorOnDeleteWhenNotExist() {
Mockito.when(teamRepository.findById(Mockito.anyString()))
.thenReturn(Mono.empty());
StepVerifier
.create(teamService.deleteTeam("some id"))
.expectErrorMatches(error -> error instanceof TeamServiceException && error.getMessage().equals("cannot find team to delete"))
.verify();
}
}

View File

@@ -0,0 +1,31 @@
package net.szymonsawicki.reactivetimesheetapp.application.service.utils;
import org.testcontainers.containers.MongoDBContainer;
public class TimesheetAppMongoDbContainer extends MongoDBContainer {
private static final String IMAGE_VERSION = "mongo:4.0.10";
private static MongoDBContainer container;
public static synchronized MongoDBContainer getInstance() {
if (container == null) {
container = new TimesheetAppMongoDbContainer();
}
return container;
}
private TimesheetAppMongoDbContainer() {
super(IMAGE_VERSION);
}
@Override
public void start() {
super.start();
}
@Override
public void stop() {
// do nothing, JVM handles shut down
}
}