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