diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..601fc6a6 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,7 @@ +services: + redis: + container_name: redis + image: redis:latest + restart: always + ports: + - "6379:6379" \ No newline at end of file diff --git a/pom.xml b/pom.xml index 03bc446d..8bbdf4ee 100644 --- a/pom.xml +++ b/pom.xml @@ -5,23 +5,89 @@ org.springframework.boot spring-boot-starter-parent - 3.3.5 + 3.5.0 - com.emobile - SpringToDo + com.example + demo 0.0.1-SNAPSHOT - SpringToDo - SpringToDo - + demo + Demo project for Spring Boot + + + + + + + + + + + + + - 21 + 17 + 1.6.3 - org.springframework.boot - spring-boot-starter + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-cache + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.liquibase + liquibase-core + + + org.mapstruct + mapstruct + ${mapstructVersion} + + + org.mapstruct + mapstruct-processor + ${mapstructVersion} + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + 1.18.34 + provided + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-jdbc + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.8 @@ -29,15 +95,85 @@ spring-boot-starter-test test + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + org.assertj + assertj-core + 3.27.3 + test + + + org.testcontainers + postgresql + test + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + 1.18.34 + + + org.mapstruct + mapstruct-processor + ${mapstructVersion} + + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + ${project.build.directory}/jacoco-report + + XML + HTML + + + + + + org.springframework.boot spring-boot-maven-plugin - - + \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java index 41980514..0a6a54a3 100644 --- a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java +++ b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; @SpringBootApplication +@EnableCaching public class SpringToDoApplication { public static void main(String[] args) { diff --git a/src/main/java/com/emobile/springtodo/config/CacheConfig.java b/src/main/java/com/emobile/springtodo/config/CacheConfig.java new file mode 100644 index 00000000..18bbdf94 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/CacheConfig.java @@ -0,0 +1,40 @@ +package com.emobile.springtodo.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; + +import java.time.Duration; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + GenericJackson2JsonRedisSerializer serializer = + new GenericJackson2JsonRedisSerializer(objectMapper); + + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(serializer)) + .entryTtl(Duration.ofMinutes(10)) + .disableCachingNullValues(); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(config) + .build(); + } +} diff --git a/src/main/java/com/emobile/springtodo/config/SwaggerConfig.java b/src/main/java/com/emobile/springtodo/config/SwaggerConfig.java new file mode 100644 index 00000000..90d70984 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/SwaggerConfig.java @@ -0,0 +1,18 @@ +package com.emobile.springtodo.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("ToDo API") + .version("1.0") + .description("API для управления списком дел")); + } +} diff --git a/src/main/java/com/emobile/springtodo/controller/ToDoController.java b/src/main/java/com/emobile/springtodo/controller/ToDoController.java new file mode 100644 index 00000000..cebe3266 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/ToDoController.java @@ -0,0 +1,56 @@ +package com.emobile.springtodo.controller; + +import com.emobile.springtodo.dto.request.ToDoRequestDto; +import com.emobile.springtodo.dto.response.ToDoResponseDto; +import com.emobile.springtodo.service.ToDoService; +import com.emobile.springtodo.swagger.IToDoController; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.hibernate.validator.constraints.UUID; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/todo") +@RequiredArgsConstructor +@Validated +public class ToDoController implements IToDoController { + private final ToDoService toDoService; + + @GetMapping("/{id}") + public ToDoResponseDto findById(@UUID @PathVariable String id) { + return toDoService.findById(id); + } + + @GetMapping("/all") + public List findAll(@RequestParam @Positive int pageSize, + @RequestParam @Positive int pageNumber) { + return toDoService.findAll(pageSize, pageNumber); + } + + @PostMapping + public ToDoResponseDto save(@RequestBody @Validated ToDoRequestDto requestDto) { + return toDoService.save(requestDto); + } + + @PutMapping("/{id}") + public ToDoResponseDto update(@RequestBody @Validated ToDoRequestDto requestDto, + @PathVariable @UUID String id) { + return toDoService.update(requestDto, id); + } + + @DeleteMapping("/{id}") + public String delete(@PathVariable @UUID String id) { + return toDoService.delete(id); + } +} diff --git a/src/main/java/com/emobile/springtodo/dto/request/ToDoRequestDto.java b/src/main/java/com/emobile/springtodo/dto/request/ToDoRequestDto.java new file mode 100644 index 00000000..6f8b8e67 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/request/ToDoRequestDto.java @@ -0,0 +1,22 @@ +package com.emobile.springtodo.dto.request; + +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import org.hibernate.validator.constraints.Length; + +import java.time.LocalDateTime; + +@Builder +public record ToDoRequestDto( + @Length(min = 1, max = 255, message = "Description must contain from 1 to 255 characters") + @NotBlank(message = "Description can't be blank") + String description, + + @NotNull(message = "Expiration date can't be null") + @FutureOrPresent(message = "Expiration date must be in future") + LocalDateTime expirationDate, + + boolean done) { +} diff --git a/src/main/java/com/emobile/springtodo/dto/response/ToDoResponseDto.java b/src/main/java/com/emobile/springtodo/dto/response/ToDoResponseDto.java new file mode 100644 index 00000000..6a54cfba --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/response/ToDoResponseDto.java @@ -0,0 +1,14 @@ +package com.emobile.springtodo.dto.response; + +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Builder +public record ToDoResponseDto( + UUID id, + String description, + LocalDateTime expirationDate, + boolean done) { +} diff --git a/src/main/java/com/emobile/springtodo/entity/ToDo.java b/src/main/java/com/emobile/springtodo/entity/ToDo.java new file mode 100644 index 00000000..6275ffb0 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/entity/ToDo.java @@ -0,0 +1,37 @@ +package com.emobile.springtodo.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@Entity +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "todos") +public class ToDo { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @Column(name = "description", nullable = false) + private String description; + + @Column(name = "expiration_date") + private LocalDateTime expirationDate; + + @Column(name = "is_done") + private boolean done; +} diff --git a/src/main/java/com/emobile/springtodo/exception/ToDoNotFoundException.java b/src/main/java/com/emobile/springtodo/exception/ToDoNotFoundException.java new file mode 100644 index 00000000..4f0d8baf --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/ToDoNotFoundException.java @@ -0,0 +1,11 @@ +package com.emobile.springtodo.exception; + +import java.util.UUID; + +public class ToDoNotFoundException extends RuntimeException { + private static final String DEFAULT_MESSAGE = "ToDo with id %s not found"; + + public ToDoNotFoundException(UUID id) { + super(String.format(DEFAULT_MESSAGE, id.toString())); + } +} diff --git a/src/main/java/com/emobile/springtodo/exception/handler/ControllersExceptionHandler.java b/src/main/java/com/emobile/springtodo/exception/handler/ControllersExceptionHandler.java new file mode 100644 index 00000000..89d38052 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/handler/ControllersExceptionHandler.java @@ -0,0 +1,46 @@ +package com.emobile.springtodo.exception.handler; + +import com.emobile.springtodo.exception.ToDoNotFoundException; +import jakarta.validation.ConstraintViolationException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; + +import java.util.stream.Collectors; + +@RestControllerAdvice +@RequiredArgsConstructor +public class ControllersExceptionHandler { + + @ExceptionHandler(ToDoNotFoundException.class) + public ResponseEntity handleNotFoundException(Exception e, WebRequest request) { + CustomErrorResponse errorResponse = new CustomErrorResponse(e.getMessage(), HttpStatus.NOT_FOUND, request); + return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException e, WebRequest request) { + String message = e.getBindingResult().getAllErrors().stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining("; ", "", ".")); + + CustomErrorResponse errorResponse = new CustomErrorResponse(message, HttpStatus.BAD_REQUEST, request); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler({ + IllegalArgumentException.class, + HttpMessageNotReadableException.class, + ConstraintViolationException.class}) + public ResponseEntity handleIllegalArgumentException(Exception e, WebRequest request) { + CustomErrorResponse errorResponse = new CustomErrorResponse(e.getMessage(), HttpStatus.BAD_REQUEST, request); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/com/emobile/springtodo/exception/handler/CustomErrorResponse.java b/src/main/java/com/emobile/springtodo/exception/handler/CustomErrorResponse.java new file mode 100644 index 00000000..a6bf0334 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/handler/CustomErrorResponse.java @@ -0,0 +1,25 @@ +package com.emobile.springtodo.exception.handler; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.http.HttpStatus; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; + +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +public class CustomErrorResponse { + private String timestamp; + private int status; + private String error; + private String path; + + public CustomErrorResponse(String message, HttpStatus status, WebRequest request) { + this.timestamp = LocalDateTime.now().toString(); + this.status = status.value(); + this.error = message; + this.path = ((ServletWebRequest) request).getRequest().getRequestURI(); + } +} diff --git a/src/main/java/com/emobile/springtodo/mapper/JDBCToDoMapper.java b/src/main/java/com/emobile/springtodo/mapper/JDBCToDoMapper.java new file mode 100644 index 00000000..69058e83 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/mapper/JDBCToDoMapper.java @@ -0,0 +1,24 @@ +package com.emobile.springtodo.mapper; + +import com.emobile.springtodo.entity.ToDo; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.UUID; + +@Component +public class JDBCToDoMapper implements RowMapper { + + @Override + public ToDo mapRow(ResultSet rs, int rowNum) throws SQLException { + return ToDo.builder() + .id(UUID.fromString(rs.getString("id"))) + .description(rs.getString("description")) + .expirationDate(rs.getObject("expiration_date", LocalDateTime.class)) + .done(rs.getBoolean("is_done")) + .build(); + } +} diff --git a/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java b/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java new file mode 100644 index 00000000..e6522e86 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java @@ -0,0 +1,17 @@ +package com.emobile.springtodo.mapper; + +import com.emobile.springtodo.dto.request.ToDoRequestDto; +import com.emobile.springtodo.dto.response.ToDoResponseDto; +import com.emobile.springtodo.entity.ToDo; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface ToDoMapper { + @Mapping(target = "id", ignore = true) + @Mapping(source = "done", target = "done") + ToDo fromRequestToEntity(ToDoRequestDto toDoRequestDto); + + @Mapping(source = "done", target = "done") + ToDoResponseDto fromEntityToResponseDto(ToDo toDo); +} diff --git a/src/main/java/com/emobile/springtodo/metric/ToDoMetrics.java b/src/main/java/com/emobile/springtodo/metric/ToDoMetrics.java new file mode 100644 index 00000000..3d2d233a --- /dev/null +++ b/src/main/java/com/emobile/springtodo/metric/ToDoMetrics.java @@ -0,0 +1,18 @@ +package com.emobile.springtodo.metric; + +import com.emobile.springtodo.repository.ToDoRepository; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.stereotype.Component; + +@Component +public class ToDoMetrics { + + public ToDoMetrics(MeterRegistry meterRegistry, ToDoRepository toDoRepository) { + + Gauge.builder("todo.tasks.done.count", + () -> toDoRepository.countByDone(true)) // Предполагается, что такой метод есть в репозитории + .description("Количество выполненных задач (done = true)") + .register(meterRegistry); + } +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java b/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java new file mode 100644 index 00000000..8fe349fa --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java @@ -0,0 +1,16 @@ +package com.emobile.springtodo.repository; + +import com.emobile.springtodo.entity.ToDo; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ToDoRepository { + Optional findById(UUID id); + List findAll(int pageSize, int pageNumber); + ToDo save(ToDo todo); + ToDo update(ToDo todo, UUID id); + void delete(UUID id); + int countByDone(boolean done); +} diff --git a/src/main/java/com/emobile/springtodo/repository/impl/ToDoRepositoryImpl.java b/src/main/java/com/emobile/springtodo/repository/impl/ToDoRepositoryImpl.java new file mode 100644 index 00000000..9de89465 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/impl/ToDoRepositoryImpl.java @@ -0,0 +1,101 @@ +package com.emobile.springtodo.repository.impl; + +import com.emobile.springtodo.entity.ToDo; +import com.emobile.springtodo.exception.ToDoNotFoundException; +import com.emobile.springtodo.mapper.JDBCToDoMapper; +import com.emobile.springtodo.repository.ToDoRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +@RequiredArgsConstructor +public class ToDoRepositoryImpl implements ToDoRepository { + + private final JdbcTemplate jdbcTemplate; + private final JDBCToDoMapper mapper; + + + @Override + public Optional findById(UUID id) { + String query = "SELECT * FROM todos WHERE id = ?"; + try { + ToDo toDo = jdbcTemplate.queryForObject(query, mapper, id); + return Optional.ofNullable(toDo); + } catch (DataAccessException e) { + return Optional.empty(); + } + } + + @Override + public List findAll(int pageSize, int pageNumber) { + String query = "SELECT * FROM todos ORDER BY id LIMIT ? OFFSET ?"; + + int offset = (pageNumber - 1) * pageSize; + + try { + return jdbcTemplate.query(query, mapper, pageSize, offset); + } catch (DataAccessException e) { + return new ArrayList<>(); + } + } + + @Override + public ToDo save(ToDo todo) { + if (todo.getId() == null) { + todo.setId(UUID.randomUUID()); + } + + String query = "INSERT INTO todos (id, description, expiration_date, is_done) VALUES (?, ?, ?, ?)"; + + jdbcTemplate.update(query, + todo.getId(), + todo.getDescription(), + todo.getExpirationDate(), + todo.isDone()); + return todo; + } + + @Override + public ToDo update(ToDo todo, UUID id) { + String sql = "UPDATE todos SET description = ?, expiration_date = ?, is_done = ? WHERE id = ?"; + + int rowsChanged = jdbcTemplate.update(sql, + todo.getDescription(), + todo.getExpirationDate(), + todo.isDone(), + id); + if (rowsChanged == 0) { + throw new ToDoNotFoundException(id); + } + todo.setId(id); + return todo; + } + + @Override + public void delete(UUID id) { + String sql = "DELETE FROM todos WHERE id = ?"; + + int rowsChanged = jdbcTemplate.update(sql, id); + if (rowsChanged == 0) { + throw new ToDoNotFoundException(id); + } + } + + @Override + public int countByDone(boolean done) { + String query = "SELECT COUNT(*) FROM todos WHERE is_done = ?"; + try { + Integer count = jdbcTemplate.queryForObject(query, Integer.class, done); + return count != null ? count : 0; + } catch (DataAccessException e) { + return 0; + } + } +} diff --git a/src/main/java/com/emobile/springtodo/service/ToDoService.java b/src/main/java/com/emobile/springtodo/service/ToDoService.java new file mode 100644 index 00000000..0b184f8e --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/ToDoService.java @@ -0,0 +1,14 @@ +package com.emobile.springtodo.service; + +import com.emobile.springtodo.dto.request.ToDoRequestDto; +import com.emobile.springtodo.dto.response.ToDoResponseDto; + +import java.util.List; + +public interface ToDoService { + ToDoResponseDto findById(String id); + List findAll(int pageSize, int pageNumber); + ToDoResponseDto save(ToDoRequestDto toDoRequestDto); + ToDoResponseDto update(ToDoRequestDto toDoRequestDto, String id); + String delete(String id); +} diff --git a/src/main/java/com/emobile/springtodo/service/impl/ToDoServiceImpl.java b/src/main/java/com/emobile/springtodo/service/impl/ToDoServiceImpl.java new file mode 100644 index 00000000..8b6d3481 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/impl/ToDoServiceImpl.java @@ -0,0 +1,70 @@ +package com.emobile.springtodo.service.impl; + +import com.emobile.springtodo.dto.request.ToDoRequestDto; +import com.emobile.springtodo.dto.response.ToDoResponseDto; +import com.emobile.springtodo.entity.ToDo; +import com.emobile.springtodo.exception.ToDoNotFoundException; +import com.emobile.springtodo.mapper.ToDoMapper; +import com.emobile.springtodo.repository.ToDoRepository; +import com.emobile.springtodo.service.ToDoService; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ToDoServiceImpl implements ToDoService { + private final ToDoRepository toDoRepository; + private final ToDoMapper toDoMapper; + + @Override + @Cacheable(value = "todos", key = "#id") + public ToDoResponseDto findById(String id) { + return toDoRepository.findById(UUID.fromString(id)) + .map(toDoMapper::fromEntityToResponseDto) + .orElseThrow(() -> new ToDoNotFoundException(UUID.fromString(id))); + } + + @Override + @Cacheable(value = "todosList", key = "'pageSize:' + #pageSize + ':pageNumber:' + #pageNumber") + public List findAll(int pageSize, int pageNumber) { + return toDoRepository.findAll(pageSize, pageNumber) + .stream() + .map(toDoMapper::fromEntityToResponseDto) + .toList(); + } + + @Override + @CachePut(value = "todos", key = "#result.id") + @CacheEvict(value = "todosList", allEntries = true) + public ToDoResponseDto save(ToDoRequestDto toDoRequestDto) { + ToDo forSave = toDoMapper.fromRequestToEntity(toDoRequestDto); + ToDo saved = toDoRepository.save(forSave); + return toDoMapper.fromEntityToResponseDto(saved); + } + + @Override + @CachePut(value = "todos", key = "#result.id") + @CacheEvict(value = "todosList", allEntries = true) + public ToDoResponseDto update(ToDoRequestDto toDoRequestDto, String id) { + ToDo forUpdate = toDoMapper.fromRequestToEntity(toDoRequestDto); + ToDo updated = toDoRepository.update(forUpdate, UUID.fromString(id)); + return toDoMapper.fromEntityToResponseDto(updated); + } + + @Override + @Caching(evict = { + @CacheEvict(value = "todos", key = "#id"), + @CacheEvict(value = "todosList", allEntries = true) + }) + public String delete(String id) { + toDoRepository.delete(UUID.fromString(id)); + return String.format("ToDo with id: %s has been deleted", id); + } +} diff --git a/src/main/java/com/emobile/springtodo/swagger/IToDoController.java b/src/main/java/com/emobile/springtodo/swagger/IToDoController.java new file mode 100644 index 00000000..e707fc11 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/swagger/IToDoController.java @@ -0,0 +1,204 @@ +package com.emobile.springtodo.swagger; + +import com.emobile.springtodo.dto.request.ToDoRequestDto; +import com.emobile.springtodo.dto.response.ToDoResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; +import org.hibernate.validator.constraints.UUID; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +@Tag(name = "ToDo API", description = "API для управления списком дел") +public interface IToDoController { + + @Operation( + summary = "Получение задачи", + + description = "Получение задачи по id", + responses = { + @ApiResponse( + description = "Задача найдена", + responseCode = "200", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + value = """ + { + "id": "989b3265-ccc7-4480-bb9a-cf1c7026ed39", + "description": "bla bla bla", + "expirationDate": "2025-07-18T00:00:00.546", + "done": false + } + """ + ) + } + + )), + @ApiResponse( + description = "Задача не найдена", + responseCode = "404", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + value = """ + { + "timestamp": "2023-10-31T18:09:02.598535700Z" + "status":404, + "error":"ToDo with id 859b3265-ccc7-4480-bb9a-cf1c7026ed39 not found", + "path":"/todo/859b3265-ccc7-4480-bb9a-cf1c7026ed39" + } + """ + ) + } + )) + } + ) + ToDoResponseDto findById(@UUID @PathVariable String id); + + @Operation( + summary = "Получение всего списка дел", + description = "Получение всего списка дел с пагинацией", + responses = { + @ApiResponse( + description = "Список дел получен", + responseCode = "200", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + value = """ + [ + { + "id":"989b3265-ccc7-4480-bb9a-cf1c7026ed39", + "description":"bla bla bla", + "expirationDate":"2025-07-18T00:00:00.546", + "done":false + }, + { + "id":"cd3d29fe-6991-4cb3-992d-cef17e91decb", + "description":"bla2 bla bla", + "expirationDate":"2025-07-15T00:00:00", + "done":true + } + ]""" + ) + } + )) + } + ) + List findAll(@RequestParam @Positive int pageSize, @RequestParam @Positive int pageNumber); + + @Operation( + summary = "Сохранение новой задачи", + description = "Сохраняет задачу", + responses = { + @ApiResponse( + description = "Задача сохранена", + responseCode = "200", + content = @Content( + examples = { + @ExampleObject( + value = """ + { + "id":"cd3d29fe-6991-4cb3-992d-cef17e91decb", + "description":"Hello World", + "expirationDate":"2025-11-25T12:59:10", + "done":false + }""" + ) + } + )) + } + ) + ToDoResponseDto save(@RequestBody @Validated ToDoRequestDto requestDto); + + @Operation( + summary = "Обновление задачи", + description = "Обновление задачи по id", + responses = { + @ApiResponse( + description = "Задача обновлена", + responseCode = "200", + content = @Content( + examples = { + @ExampleObject( + value = """ + { + "id":"cd3d29fe-6991-4cb3-992d-cef17e91decb", + "description":"Hello World", + "expirationDate":"2025-11-25T12:59:10", + "done":false + }""" + ) + } + + )), + @ApiResponse( + description = "Задача не найдена", + responseCode = "404", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + value = """ + { + "timestamp": "2023-10-31T18:09:02.598535700Z" + "status":404, + "error":"ToDo with id 859b3265-ccc7-4480-bb9a-cf1c7026ed39 not found", + "path":"/todo/859b3265-ccc7-4480-bb9a-cf1c7026ed39" + } + """ + ) + } + )) + } + ) + ToDoResponseDto update(@RequestBody @Validated ToDoRequestDto requestDto, @PathVariable @UUID String id); + + @Operation( + summary = "Удаление задачи", + description = "Удаление задачи по id", + responses = { + @ApiResponse( + description = "Задача удалена", + responseCode = "200", + content = @Content( + examples = { + @ExampleObject( + value = "ToDo with id: 859b3265-ccc7-4480-bb9a-cf1c7026ed39 has been deleted" + ) + } + + )), + @ApiResponse( + description = "Задача не найдена", + responseCode = "404", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + value = """ + { + "timestamp": "2023-10-31T18:09:02.598535700Z" + "status":404, + "error":"ToDo with id 859b3265-ccc7-4480-bb9a-cf1c7026ed39 not found", + "path":"/todo/859b3265-ccc7-4480-bb9a-cf1c7026ed39" + } + """ + ) + } + )) + } + ) + String delete(@PathVariable @UUID String id); +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 5c3e5461..00000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=SpringToDo diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 00000000..49111eda --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,56 @@ +server: + port: 9009 + +spring: + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/todos_db +# url: jdbc:postgresql://PostgreSQL:5432/socks_db + username: postgres + password: postgres + hikari: + schema: public + + autoconfigure: + exclude: + + jpa: + database: postgresql + database-platform: org.hibernate.dialect.PostgreSQLDialect + generate-ddl: false + show-sql: true + hibernate: + ddl-auto: none + + liquibase: + enabled: true + change-log: classpath:/db/changelog/db.changelog-master.yaml + + data: + redis: + host: localhost + port: 6379 + jedis: + pool: + time-between-eviction-runs: 10m + +springdoc: + api-docs: + enabled: true + swagger-ui: + enabled: true + +management: + endpoints: + web: + exposure: + include: health, metrics, info + endpoint: + health: + show-details: always + +info: + app: + name: SpringToDo + version: 1.0.0 + description: week3 1st task \ No newline at end of file diff --git a/src/main/resources/db/changelog/01.000.00/db.changelog.yaml b/src/main/resources/db/changelog/01.000.00/db.changelog.yaml new file mode 100644 index 00000000..d3e9821b --- /dev/null +++ b/src/main/resources/db/changelog/01.000.00/db.changelog.yaml @@ -0,0 +1,11 @@ +databaseChangeLog: + - include: + file: todos.sql + relativeToChangelogFile: true + - changeSet: + id: 01.000.00 + author: Siarhei_Kavaleu + logicalFilePath: 01.000.00/db.changelog.yaml + changes: + - tagDatabase: + tag: 01.000.00 \ No newline at end of file diff --git a/src/main/resources/db/changelog/01.000.00/todos.sql b/src/main/resources/db/changelog/01.000.00/todos.sql new file mode 100644 index 00000000..b9d1d4cc --- /dev/null +++ b/src/main/resources/db/changelog/01.000.00/todos.sql @@ -0,0 +1,10 @@ +--liquibase formatted sql +--changeset Siarhei_Kavaleu:db localFilePath:01.000.00/todos.sql +CREATE TABLE todos +( + id UUID NOT NULL, + description VARCHAR(255) NOT NULL, + expiration_date DATE NOT NULL, + is_done BOOLEAN NOT NULL, + CONSTRAINT pk_todos PRIMARY KEY (id) +); \ No newline at end of file diff --git a/src/main/resources/db/changelog/01.000.01/db.changelog.yaml b/src/main/resources/db/changelog/01.000.01/db.changelog.yaml new file mode 100644 index 00000000..6a550b13 --- /dev/null +++ b/src/main/resources/db/changelog/01.000.01/db.changelog.yaml @@ -0,0 +1,11 @@ +databaseChangeLog: + - include: + file: todos.sql + relativeToChangelogFile: true + - changeSet: + id: 01.000.01 + author: Siarhei_Kavaleu + logicalFilePath: 01.000.01/db.changelog.yaml + changes: + - tagDatabase: + tag: 01.000.01 \ No newline at end of file diff --git a/src/main/resources/db/changelog/01.000.01/todos.sql b/src/main/resources/db/changelog/01.000.01/todos.sql new file mode 100644 index 00000000..d806da40 --- /dev/null +++ b/src/main/resources/db/changelog/01.000.01/todos.sql @@ -0,0 +1,3 @@ +--liquibase formatted sql +--changeset Siarhei_Kavaleu:db localFilePath:01.000.01/todos.sql +ALTER TABLE todos ALTER COLUMN expiration_date TYPE timestamp; \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml new file mode 100644 index 00000000..5a7e2f0c --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,7 @@ +databaseChangeLog: + - include: + file: 01.000.00/db.changelog.yaml + relativeToChangelogFile: true + - include: + file: 01.000.01/db.changelog.yaml + relativeToChangelogFile: true \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/SpringToDoApplicationTests.java b/src/test/java/com/emobile/springtodo/SpringToDoApplicationTests.java deleted file mode 100644 index 3114a50b..00000000 --- a/src/test/java/com/emobile/springtodo/SpringToDoApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.emobile.springtodo; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class SpringToDoApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java b/src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java new file mode 100644 index 00000000..d4e57a75 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java @@ -0,0 +1,211 @@ +package com.emobile.springtodo.controller; + +import com.emobile.springtodo.dto.request.ToDoRequestDto; +import com.emobile.springtodo.dto.response.ToDoResponseDto; +import com.emobile.springtodo.exception.ToDoNotFoundException; +import com.emobile.springtodo.exception.handler.ControllersExceptionHandler; +import com.emobile.springtodo.service.ToDoService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@AutoConfigureMockMvc +@ExtendWith(MockitoExtension.class) +class ToDoControllerTest { + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @Mock + private ToDoService service; + @InjectMocks + private ToDoController controller; + + private String id; + private ToDoRequestDto requestDto; + private ToDoResponseDto responseDto; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders + .standaloneSetup(controller) + .setControllerAdvice(new ControllersExceptionHandler()) + .build(); + objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()); + + id = "1fcc470d-a691-4d0d-9a23-b8b455c5f586"; + String description = "bla bla bla"; + LocalDateTime expirationDate = LocalDateTime.now().plusYears(1); + boolean done = false; + + requestDto = ToDoRequestDto.builder() + .description(description) + .expirationDate(expirationDate) + .done(done) + .build(); + + responseDto = ToDoResponseDto.builder() + .id(UUID.fromString(id)) + .description(description) + .expirationDate(expirationDate) + .done(done) + .build(); + } + + @Test + @DisplayName("should return response when found by id") + void findById_whenPresent() throws Exception { + String jsonResponseQuery = objectMapper.writeValueAsString(responseDto); + + when(service.findById(id)).thenReturn(responseDto); + + mockMvc.perform(MockMvcRequestBuilders.get("/todo/{id}", id) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(jsonResponseQuery)) + .andDo(print()); + + verify(service).findById(id); + } + + @Test + @DisplayName("should return error when not found by id") + void findById_whenNotPresent() throws Exception { + when(service.findById(id)).thenThrow(new ToDoNotFoundException(UUID.fromString(id))); + + mockMvc.perform(MockMvcRequestBuilders.get("/todo/{id}", id) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.error").value(String.format("ToDo with id %s not found", id))) + .andDo(print()); + } + + @Test + @DisplayName("should return list of all founded todos") + void findAll() throws Exception { + int pageSize = 5; + int pageNumber = 1; + List responseDtoList = List.of(responseDto, responseDto); + + when(service.findAll(pageSize, pageNumber)).thenReturn(responseDtoList); + + String expectedJson = objectMapper.writeValueAsString(responseDtoList); + + mockMvc.perform(MockMvcRequestBuilders.get("/todo/all") + .param("pageSize", String.valueOf(pageSize)) + .param("pageNumber", String.valueOf(pageNumber)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(content().json(expectedJson)) + .andDo(print()); + } + + @Test + @DisplayName("should save ToDo and return it") + void save() throws Exception { + when(service.save(any(ToDoRequestDto.class))).thenReturn(responseDto); + + String requestJson = objectMapper.writeValueAsString(requestDto); + String responseJson = objectMapper.writeValueAsString(responseDto); + + mockMvc.perform(MockMvcRequestBuilders.post("/todo") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(content().json(responseJson)) + .andDo(print()); + } + + @Test + @DisplayName("should update todo by id when it present in db") + void update_whenPresent() throws Exception { + when(service.update(any(ToDoRequestDto.class), eq(id))).thenReturn(responseDto); + + String requestJson = objectMapper.writeValueAsString(requestDto); + String responseJson = objectMapper.writeValueAsString(responseDto); + + mockMvc.perform(MockMvcRequestBuilders.put("/todo/{id}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(content().json(responseJson)); + } + + @Test + @DisplayName("should return error of updating todo when id not present in db") + void update_whenNotPresent() throws Exception { + when(service.update(any(ToDoRequestDto.class), eq(id))) + .thenThrow(new ToDoNotFoundException(UUID.fromString(id))); + + String requestJson = objectMapper.writeValueAsString(requestDto); + + mockMvc.perform(MockMvcRequestBuilders.put("/todo/{id}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.error").value(String.format("ToDo with id %s not found", id))) + .andDo(print()); + } + + @Test + @DisplayName("should delete todo by id when it present in db") + void delete_whenPresent() throws Exception { + String expectedMessage = String.format("ToDo with id: %s has been deleted", id); + + when(service.delete(id)).thenReturn(expectedMessage); + + mockMvc.perform(MockMvcRequestBuilders.delete("/todo/{id}", id) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(expectedMessage)) + .andDo(print()); + + verify(service).delete(id); + } + + @Test + @DisplayName("should return error of deleting todo when id not present in db") + void delete_whenNotPresent() throws Exception { + when(service.delete(id)).thenThrow(new ToDoNotFoundException(UUID.fromString(id))); + + mockMvc.perform(MockMvcRequestBuilders.delete("/todo/{id}", id) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.error").value(String.format("ToDo with id %s not found", id))) + .andDo(print()); + + verify(service).delete(id); + } +} \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/integration/ToDoControllerIntegrationTest.java b/src/test/java/com/emobile/springtodo/integration/ToDoControllerIntegrationTest.java new file mode 100644 index 00000000..4faf891a --- /dev/null +++ b/src/test/java/com/emobile/springtodo/integration/ToDoControllerIntegrationTest.java @@ -0,0 +1,247 @@ +package com.emobile.springtodo.integration; + +import com.emobile.springtodo.dto.request.ToDoRequestDto; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.http.MediaType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.LocalDateTime; +import java.time.Month; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +@AutoConfigureMockMvc +@Transactional +@Sql({"/todo-data-init.sql"}) +class ToDoControllerIntegrationTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private RedisConnectionFactory redisConnectionFactory; + + @Container + @ServiceConnection + static GenericContainer redisContainer = new GenericContainer<>("redis:8.0.2") + .withExposedPorts(6379); + + @Container + @ServiceConnection + static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer<>("postgres:16.0"); + + + @BeforeEach + void setUp() { + objectMapper.registerModule(new JavaTimeModule()); + try (RedisConnection connection = redisConnectionFactory.getConnection()) { + connection.execute("FLUSHDB"); + } + } + + @Test + @DisplayName("should return response when found by id") + void getToDo() throws Exception { + String id = "989b3265-ccc7-4480-bb9a-cf1c7026ed39"; + String expectedJson = """ + { + "id": "989b3265-ccc7-4480-bb9a-cf1c7026ed39", + "description": "bla bla bla", + "expirationDate": '2025-07-18T00:00:00.546', + "done": false + }"""; + + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/todo/" + id)) + .andExpect(status().isOk()) + .andReturn(); + + JSONAssert.assertEquals(expectedJson, result.getResponse().getContentAsString(), false); + } + + @Test + @DisplayName("should return error when not found by id") + void getToDo_notFound() throws Exception { + String id = "859b3265-ccc7-4480-bb9a-cf1c7026ed39"; + String expectedJson = """ + { + "status":404, + "error":"ToDo with id 859b3265-ccc7-4480-bb9a-cf1c7026ed39 not found", + "path":"/todo/859b3265-ccc7-4480-bb9a-cf1c7026ed39" + }"""; + + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/todo/" + id)) + .andExpect(status().isNotFound()) + .andReturn(); + + JSONAssert.assertEquals(expectedJson, result.getResponse().getContentAsString(), false); + } + + @Test + @DisplayName("should return list of all founded todos") + void findAll() throws Exception { + String expectedJson = """ + [ + { + "id":"989b3265-ccc7-4480-bb9a-cf1c7026ed39", + "description":"bla bla bla", + "expirationDate":"2025-07-18T00:00:00.546", + "done":false + }, + { + "id":"cd3d29fe-6991-4cb3-992d-cef17e91decb", + "description":"bla2 bla bla", + "expirationDate":"2025-07-15T00:00:00", + "done":true + } + ]"""; + + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/todo/all") + .param("pageSize", "5") + .param("pageNumber", "1")) + .andExpect(status().isOk()) + .andReturn(); + + JSONAssert.assertEquals(expectedJson, result.getResponse().getContentAsString(), false); + } + + @Test + @DisplayName("should save ToDo and return it") + void save() throws Exception { + ToDoRequestDto requestDto = ToDoRequestDto.builder() + .description("Hello World") + .expirationDate(LocalDateTime.of(2025, Month.NOVEMBER, 25, 12, 59, 10)) + .done(false) + .build(); + String requestStr = objectMapper.writeValueAsString(requestDto); + + String expectedJson = """ + { + "description":"Hello World", + "expirationDate":"2025-11-25T12:59:10", + "done":false + }"""; + + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.post("/todo") + .contentType(MediaType.APPLICATION_JSON) + .content(requestStr)) + .andExpect(status().isOk()) + .andReturn(); + + JSONAssert.assertEquals(expectedJson, result.getResponse().getContentAsString(), false); + } + + @Test + @DisplayName("should update todo by id when it present in db") + void update_whenPresent() throws Exception { + String id = "cd3d29fe-6991-4cb3-992d-cef17e91decb"; + + ToDoRequestDto requestDto = ToDoRequestDto.builder() + .description("Hello World") + .expirationDate(LocalDateTime.of(2025, Month.NOVEMBER, 25, 12, 59, 10)) + .done(false) + .build(); + + String requestStr = objectMapper.writeValueAsString(requestDto); + + String expectedJson = """ + { + "id":"cd3d29fe-6991-4cb3-992d-cef17e91decb", + "description":"Hello World", + "expirationDate":"2025-11-25T12:59:10", + "done":false + }"""; + + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.put("/todo/{id}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(requestStr)) + .andExpect(status().isOk()) + .andReturn(); + + JSONAssert.assertEquals(expectedJson, result.getResponse().getContentAsString(), false); + } + + @Test + @DisplayName("should return error of updating todo when id not present in db") + void update_whenNotPresent() throws Exception { + String id = "859b3265-ccc7-4480-bb9a-cf1c7026ed39"; + + ToDoRequestDto requestDto = ToDoRequestDto.builder() + .description("Hello World") + .expirationDate(LocalDateTime.of(2025, Month.NOVEMBER, 25, 12, 59, 10)) + .done(false) + .build(); + + String requestStr = objectMapper.writeValueAsString(requestDto); + + String expectedJson = """ + { + "status":404, + "error":"ToDo with id 859b3265-ccc7-4480-bb9a-cf1c7026ed39 not found", + "path":"/todo/859b3265-ccc7-4480-bb9a-cf1c7026ed39" + }"""; + + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.put("/todo/{id}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(requestStr)) + .andExpect(status().isNotFound()) + .andReturn(); + JSONAssert.assertEquals(expectedJson, result.getResponse().getContentAsString(), false); + } + + @Test + @DisplayName("should delete todo by id when it present in db") + void delete_whenPresent() throws Exception { + String id = "cd3d29fe-6991-4cb3-992d-cef17e91decb"; + + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.delete("/todo/{id}", id)) + .andExpect(status().isOk()) + .andReturn(); + + String response = result.getResponse().getContentAsString(); + + String expectedMessage = String.format("ToDo with id: %s has been deleted", id); + + assertThat(response).isEqualTo(expectedMessage); + } + + @Test + @DisplayName("should return error of deleting todo when id not present in db") + void delete_whenNotPresent() throws Exception { + String id = "859b3265-ccc7-4480-bb9a-cf1c7026ed39"; + + String expectedJson = """ + { + "status":404, + "error":"ToDo with id 859b3265-ccc7-4480-bb9a-cf1c7026ed39 not found", + "path":"/todo/859b3265-ccc7-4480-bb9a-cf1c7026ed39" + }"""; + + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.delete("/todo/{id}", id)) + .andExpect(status().isNotFound()) + .andReturn(); + + JSONAssert.assertEquals(expectedJson, result.getResponse().getContentAsString(), false); + } +} diff --git a/src/test/java/com/emobile/springtodo/mapper/ToDoMapperTest.java b/src/test/java/com/emobile/springtodo/mapper/ToDoMapperTest.java new file mode 100644 index 00000000..58b4d69d --- /dev/null +++ b/src/test/java/com/emobile/springtodo/mapper/ToDoMapperTest.java @@ -0,0 +1,64 @@ +package com.emobile.springtodo.mapper; + +import com.emobile.springtodo.dto.request.ToDoRequestDto; +import com.emobile.springtodo.dto.response.ToDoResponseDto; +import com.emobile.springtodo.entity.ToDo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class ToDoMapperTest { + + private final ToDoMapper mapper = new ToDoMapperImpl(); + + private ToDo todo; + private ToDoRequestDto requestDto; + private ToDoResponseDto responseDto; + + @BeforeEach + void setUp() { + String id = "e487f331-5b7d-40b9-b49d-b41f94b2c960"; + todo = ToDo.builder() + .id(UUID.fromString(id)) + .description("bla bla bla") + .expirationDate(LocalDateTime.now().plusYears(1)) + .done(false) + .build(); + + responseDto = ToDoResponseDto.builder() + .id(todo.getId()) + .description(todo.getDescription()) + .expirationDate(todo.getExpirationDate()) + .done(todo.isDone()) + .build(); + + requestDto = ToDoRequestDto.builder() + .description(todo.getDescription()) + .expirationDate(todo.getExpirationDate()) + .done(todo.isDone()) + .build(); + } + + @Test + @DisplayName("should convert requestDto to Entity") + void fromRequestToEntity() { + todo.setId(null); + + ToDo actual = mapper.fromRequestToEntity(requestDto); + + assertThat(actual).isEqualTo(todo); + } + + @Test + @DisplayName("should convert Entity to responseDto") + void fromEntityToResponseDto() { + ToDoResponseDto actual = mapper.fromEntityToResponseDto(todo); + + assertThat(actual).isEqualTo(responseDto); + } +} \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/service/impl/ToDoServiceImplTest.java b/src/test/java/com/emobile/springtodo/service/impl/ToDoServiceImplTest.java new file mode 100644 index 00000000..a8d6cd75 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/service/impl/ToDoServiceImplTest.java @@ -0,0 +1,194 @@ +package com.emobile.springtodo.service.impl; + +import com.emobile.springtodo.dto.request.ToDoRequestDto; +import com.emobile.springtodo.dto.response.ToDoResponseDto; +import com.emobile.springtodo.entity.ToDo; +import com.emobile.springtodo.exception.ToDoNotFoundException; +import com.emobile.springtodo.mapper.ToDoMapper; +import com.emobile.springtodo.repository.ToDoRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ToDoServiceImplTest { + @Mock + private ToDoRepository repository; + @Mock + private ToDoMapper mapper; + @InjectMocks + private ToDoServiceImpl service; + + private String id; + private ToDo todo; + private ToDoResponseDto responseDto; + private ToDoRequestDto requestDto; + + @BeforeEach + void setUp() { + id = "e487f331-5b7d-40b9-b49d-b41f94b2c960"; + todo = ToDo.builder() + .id(UUID.fromString(id)) + .description("bla bla bla") + .expirationDate(LocalDateTime.now().plusYears(1)) + .done(false) + .build(); + + responseDto = ToDoResponseDto.builder() + .id(todo.getId()) + .description(todo.getDescription()) + .expirationDate(todo.getExpirationDate()) + .done(todo.isDone()) + .build(); + + requestDto = ToDoRequestDto.builder() + .description(todo.getDescription()) + .expirationDate(todo.getExpirationDate()) + .done(todo.isDone()) + .build(); + } + + @Test + @DisplayName("should find ToDo by ID when present") + void findById_whenPresent() { + when(repository.findById(any(UUID.class))) + .thenReturn(Optional.of(todo)); + when(mapper.fromEntityToResponseDto(todo)) + .thenReturn(responseDto); + + ToDoResponseDto actual = service.findById(id); + + assertThat(actual).isEqualTo(responseDto); + } + + @Test + @DisplayName("should throw ToDoNotFoundException when ToDo not found by ID") + void findById_whenNotPresent() { + when(repository.findById(any(UUID.class))) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.findById(id)) + .isInstanceOf(ToDoNotFoundException.class) + .hasMessageContaining(String.format("ToDo with id %s not found", id)); + + verify(mapper, never()).fromEntityToResponseDto(any()); + } + + @Test + @DisplayName("should return all ToDos as responseDTOs") + void findAll() { + List expected = List.of(responseDto, responseDto, responseDto); + + int pageSize = 5, pageNumber = 1; + List toDos = List.of(todo, todo, todo); + + when(repository.findAll(pageSize, pageNumber)) + .thenReturn(toDos); + when(mapper.fromEntityToResponseDto(any(ToDo.class))) + .thenReturn(responseDto); + + List actual = service.findAll(pageSize, pageNumber); + + assertThat(actual) + .isNotEmpty() + .containsExactlyElementsOf(expected) + .hasSameSizeAs(expected); + } + + @Test + @DisplayName("should save requestToDo and return responseDTO") + void save() { + when(mapper.fromRequestToEntity(any(ToDoRequestDto.class))) + .thenReturn(todo); + when(repository.save(any(ToDo.class))) + .thenReturn(todo); + when(mapper.fromEntityToResponseDto(todo)) + .thenReturn(responseDto); + + ToDoResponseDto actual = service.save(requestDto); + + assertThat(actual).isEqualTo(responseDto); + + verify(mapper, times(1)).fromEntityToResponseDto(any(ToDo.class)); + verify(mapper, times(1)).fromRequestToEntity(any(ToDoRequestDto.class)); + } + + @Test + @DisplayName("should update an existing ToDo when present") + void update_whenPresent() { + when(mapper.fromRequestToEntity(any(ToDoRequestDto.class))) + .thenReturn(todo); + when(repository.update(any(ToDo.class), any(UUID.class))) + .thenReturn(todo); + when(mapper.fromEntityToResponseDto(todo)) + .thenReturn(responseDto); + + ToDoResponseDto actual = service.update(requestDto, id); + + assertThat(actual).isEqualTo(responseDto); + + verify(mapper, times(1)).fromRequestToEntity(any(ToDoRequestDto.class)); + verify(mapper, times(1)).fromEntityToResponseDto(any(ToDo.class)); + } + + @Test + @DisplayName("should throw ToDoNotFoundException when updating a non-existent ToDo") + void update_whenNotPresent() { + when(mapper.fromRequestToEntity(any(ToDoRequestDto.class))) + .thenReturn(todo); + when(repository.update(any(ToDo.class), any(UUID.class))) + .thenThrow(new ToDoNotFoundException(UUID.fromString(id))); + + assertThatThrownBy(() -> service.update(requestDto, id)) + .isInstanceOf(ToDoNotFoundException.class) + .hasMessageContaining(String.format("ToDo with id %s not found", id)); + + verify(mapper, times(1)).fromRequestToEntity(any(ToDoRequestDto.class)); + verify(mapper, never()).fromEntityToResponseDto(any(ToDo.class)); + } + + @Test + @DisplayName("should delete ToDo by ID when present") + void delete_whenPresent() { + doNothing().when(repository).delete(any(UUID.class)); + + String actual = service.delete(id); + + assertThat(actual).isEqualTo(String.format("ToDo with id: %s has been deleted", id)); + verify(repository, times(1)).delete(any(UUID.class)); + } + + @Test + @DisplayName("should throw ToDoNotFoundException when deleting a non-existent ToDo") + void delete_whenNotPresent() { + doThrow(new ToDoNotFoundException(UUID.fromString(id))) + .when(repository).delete(any(UUID.class)); + + UUID uuid = UUID.fromString(id); + + assertThatThrownBy(() -> repository.delete(uuid)) + .isInstanceOf(ToDoNotFoundException.class) + .hasMessageContaining(String.format("ToDo with id %s not found", id)); + + verify(repository, times(1)).delete(any(UUID.class)); + } +} \ No newline at end of file diff --git a/src/test/resources/todo-data-init.sql b/src/test/resources/todo-data-init.sql new file mode 100644 index 00000000..a83ffc30 --- /dev/null +++ b/src/test/resources/todo-data-init.sql @@ -0,0 +1,4 @@ +INSERT INTO todos (id, description, expiration_date, is_done) +VALUES + ('989b3265-ccc7-4480-bb9a-cf1c7026ed39', 'bla bla bla', '2025-07-18 00:00:00.546', false), + ('cd3d29fe-6991-4cb3-992d-cef17e91decb', 'bla2 bla bla', '2025-07-15 00:00:00.000', true); \ No newline at end of file