diff --git a/pom.xml b/pom.xml index 03bc446d..c8b0b139 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ SpringToDo - 21 + 17 @@ -23,12 +23,91 @@ org.springframework.boot spring-boot-starter - + + org.testcontainers + testcontainers + test + + + org.skyscreamer + jsonassert + test + + + org.testcontainers + postgresql + test + + + org.testcontainers + junit-jupiter + test + + + org.springframework.boot + spring-boot-starter-data-jdbc + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-cache + + + org.springframework.boot + spring-boot-starter-data-redis + + + io.micrometer + micrometer-registry-prometheus + + + org.springframework.boot + spring-boot-starter-actuator + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-validation + + + org.projectlombok + lombok + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + org.springframework.boot spring-boot-starter-test test + + org.liquibase + liquibase-core + + + + io.micrometer + micrometer-core + 1.16.0-M2 + + + jakarta.validation + jakarta.validation-api + + + org.mapstruct + mapstruct + 1.6.3 + @@ -37,6 +116,33 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + + + + org.projectlombok + lombok + 1.18.32 + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + diff --git a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java index 41980514..e50bb340 100644 --- a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java +++ b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java @@ -5,9 +5,7 @@ @SpringBootApplication public class SpringToDoApplication { - public static void main(String[] args) { SpringApplication.run(SpringToDoApplication.class, args); } - } diff --git a/src/main/java/com/emobile/springtodo/configuration/RedisConfig.java b/src/main/java/com/emobile/springtodo/configuration/RedisConfig.java new file mode 100644 index 00000000..5f2f2f4e --- /dev/null +++ b/src/main/java/com/emobile/springtodo/configuration/RedisConfig.java @@ -0,0 +1,71 @@ +package com.emobile.springtodo.configuration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import org.springframework.cache.CacheManager; +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.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +@Configuration +@EnableCaching +public class RedisConfig { + + @Bean + public GenericJackson2JsonRedisSerializer redisJsonSerializer() { + ObjectMapper om = JsonMapper.builder() + .findAndAddModules() // JavaTimeModule и др. + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); + + // Важно: включаем type info, чтобы при чтении восстанавливался TaskResponse, а не LinkedHashMap + om.activateDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.emobile.springtodo") // ограничь своими пакетами + .allowIfSubType("java.util") + .build(), + ObjectMapper.DefaultTyping.NON_FINAL + ); + + return new GenericJackson2JsonRedisSerializer(om); + } + + @Bean + public CacheManager cacheManager(RedisConnectionFactory cf, + GenericJackson2JsonRedisSerializer jsonSer) { + RedisCacheConfiguration base = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSer)) + .disableCachingNullValues() + .computePrefixWith(name -> "todo:" + name + "::") + .entryTtl(Duration.ofMinutes(30)); + + return RedisCacheManager.builder(cf) + .cacheDefaults(base) + .transactionAware() + .build(); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory cf, + GenericJackson2JsonRedisSerializer jsonSer) { + RedisTemplate tpl = new RedisTemplate<>(); + tpl.setConnectionFactory(cf); + tpl.setKeySerializer(new StringRedisSerializer()); + tpl.setValueSerializer(jsonSer); + tpl.setHashKeySerializer(new StringRedisSerializer()); + tpl.setHashValueSerializer(jsonSer); + return tpl; + } +} diff --git a/src/main/java/com/emobile/springtodo/controllers/ControllerAdvice.java b/src/main/java/com/emobile/springtodo/controllers/ControllerAdvice.java new file mode 100644 index 00000000..81a1d251 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controllers/ControllerAdvice.java @@ -0,0 +1,17 @@ +package com.emobile.springtodo.controllers; + +import com.emobile.springtodo.dto.ApiResponse; +import com.emobile.springtodo.exceptions.TaskNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import com.emobile.springtodo.dto.ErrorResponse; +import org.springframework.web.bind.annotation.ResponseStatus; + +@org.springframework.web.bind.annotation.ControllerAdvice +public class ControllerAdvice { + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(TaskNotFoundException.class) + public ApiResponse TaskNotFoundExceptionHandler(TaskNotFoundException exception) { + return ApiResponse.error(new ErrorResponse("ACCESS_ERROR", exception.getMessage())); + } +} diff --git a/src/main/java/com/emobile/springtodo/controllers/GlobalExceptionHandler.java b/src/main/java/com/emobile/springtodo/controllers/GlobalExceptionHandler.java new file mode 100644 index 00000000..1c12197e --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controllers/GlobalExceptionHandler.java @@ -0,0 +1,92 @@ +package com.emobile.springtodo.controllers; + +import com.emobile.springtodo.dto.ApiResponse; +import com.emobile.springtodo.dto.ErrorResponse; +import jakarta.validation.ConstraintViolationException; +import org.springframework.http.*; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestControllerAdvice +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + protected ApiResponse handleMethodArgumentNotValid( + MethodArgumentNotValidException ex) { + + List> errors = ex.getBindingResult().getFieldErrors().stream() + .map(fe -> { + Map m = new LinkedHashMap<>(); + m.put("field", fe.getField()); + m.put("message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value"); + Object rejected = fe.getRejectedValue(); + if (rejected != null) { + m.put("rejectedValue", rejected); + } + return m; + }) + .toList(); + + ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + pd.setTitle("Validation failed"); + pd.setDetail("Invalid request body"); + pd.setProperty("errors", errors); + return ApiResponse.error(new ErrorResponse("VALIDATION_ERROR", pd.toString())); + } + + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleConstraintViolation(ConstraintViolationException ex) { + List> errors = ex.getConstraintViolations().stream() + .map(cv -> { + Map m = new LinkedHashMap<>(); + m.put("field", cv.getPropertyPath().toString()); + m.put("message", cv.getMessage() != null ? cv.getMessage() : "Invalid value"); + Object invalid = cv.getInvalidValue(); + if (invalid != null) { + m.put("rejectedValue", invalid); + } + return m; + }) + .toList(); + + ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + pd.setTitle("Validation failed"); + pd.setDetail("Invalid request parameters"); + pd.setProperty("errors", errors); + return ApiResponse.error(new ErrorResponse("VALIDATION_ERROR", pd.toString())); + } + + @ExceptionHandler(BindException.class) + protected ApiResponse handleBindException(BindException ex) { + + List> errors = ex.getBindingResult().getFieldErrors().stream() + .map(fe -> { + Map m = new LinkedHashMap<>(); + m.put("field", fe.getField()); + m.put("message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value"); + Object rejected = fe.getRejectedValue(); + if (rejected != null) { + m.put("rejectedValue", rejected); + } + return m; + }) + .toList(); + + ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + pd.setTitle("Validation failed"); + pd.setDetail("Binding error"); + pd.setProperty("errors", errors); + return ApiResponse.ok(new ErrorResponse("VALIDATION_ERROR", pd.toString())); + } +} diff --git a/src/main/java/com/emobile/springtodo/controllers/TaskController.java b/src/main/java/com/emobile/springtodo/controllers/TaskController.java new file mode 100644 index 00000000..a2a47a14 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controllers/TaskController.java @@ -0,0 +1,67 @@ +package com.emobile.springtodo.controllers; + +import com.emobile.springtodo.dto.*; +import com.emobile.springtodo.services.TaskServiceInterface; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + + +import java.util.Collections; +import java.util.List; + +@Slf4j +@RestController +@Validated +@RequiredArgsConstructor +@RequestMapping("/api/tasks") +public class TaskController { + private final TaskServiceInterface service; + + @GetMapping + public ApiResponse> getAllTasks( + @RequestParam(defaultValue = "10") @Positive int limit, + @RequestParam(defaultValue = "0") @PositiveOrZero int offset) { + + PaginatedResult taskResponses = service.getAllTasks(limit, offset); + + PageResponse pageResponse = new PageResponse<>( + taskResponses.items(), + offset / limit, + limit, + taskResponses.totalCount() + ); + + return ApiResponse.ok(pageResponse); + } + + @GetMapping("/{id}") + public ApiResponse getTaskById(@Positive @PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createTask(@Valid @RequestBody TaskCreateRequest taskCreateRequest) { + TaskResponse taskResponse = service.create(taskCreateRequest); + return ApiResponse.created(taskResponse); + } + + @PutMapping("/{id}") + public ApiResponse updateTask(@Positive @PathVariable Long id, + @Valid @RequestBody TaskUpdateRequest taskUpdateRequest) { + return ApiResponse.ok(service.updateTask(id, taskUpdateRequest)); + } + + @DeleteMapping("/{id}") + public ApiResponse deleteTask(@Positive @PathVariable Long id) { + service.deleteTask(id); + return ApiResponse.ok("Task delete successfully"); + } +} diff --git a/src/main/java/com/emobile/springtodo/dto/ApiResponse.java b/src/main/java/com/emobile/springtodo/dto/ApiResponse.java new file mode 100644 index 00000000..4d09cd54 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/ApiResponse.java @@ -0,0 +1,28 @@ +package com.emobile.springtodo.dto; + +import java.time.Instant; +import java.util.Map; + +public record ApiResponse( + boolean success, + T data, + ErrorResponse error, + Map meta, + Instant timestamp +) { + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, data, null, null, Instant.now()); + } + public static ApiResponse created(T data) { + return new ApiResponse<>(true, data, null, null, Instant.now()); + } + public static ApiResponse noContent() { + return new ApiResponse<>(true, null, null, null, Instant.now()); + } + public static ApiResponse error(ErrorResponse error) { + return new ApiResponse<>(false, null, error, null, Instant.now()); + } + public ApiResponse withMeta(Map meta) { + return new ApiResponse<>(this.success, this.data, this.error, meta, this.timestamp); + } +} diff --git a/src/main/java/com/emobile/springtodo/dto/ErrorResponse.java b/src/main/java/com/emobile/springtodo/dto/ErrorResponse.java new file mode 100644 index 00000000..4da5b3c9 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/ErrorResponse.java @@ -0,0 +1,27 @@ +package com.emobile.springtodo.dto; + +public class ErrorResponse { + private String code; + private String message; + + public ErrorResponse(String code, String message) { + this.code = code; + this.message = message; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/emobile/springtodo/dto/PageResponse.java b/src/main/java/com/emobile/springtodo/dto/PageResponse.java new file mode 100644 index 00000000..0a6b124b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/PageResponse.java @@ -0,0 +1,29 @@ +package com.emobile.springtodo.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class PageResponse { + private List content; // Сам список элементов на текущей странице + private int currentPage; // Номер текущей страницы (обычно с 0) + private int pageSize; // Количество элементов на странице + private long totalElements; // Общее количество элементов во всей коллекции + private int totalPages; // Общее количество страниц + + public PageResponse(List content, int currentPage, int pageSize, long totalElements) { + this.content = content; + this.currentPage = currentPage; + this.pageSize = pageSize; + this.totalElements = totalElements; + // Рассчитываем общее количество страниц + if (pageSize > 0) { + this.totalPages = (int) Math.ceil((double) totalElements / pageSize); + } else { + this.totalPages = 0; + } + } +} diff --git a/src/main/java/com/emobile/springtodo/dto/PaginatedResult.java b/src/main/java/com/emobile/springtodo/dto/PaginatedResult.java new file mode 100644 index 00000000..ed1dc027 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/PaginatedResult.java @@ -0,0 +1,8 @@ +package com.emobile.springtodo.dto; + +import java.util.List; + +public record PaginatedResult( + List items, + long totalCount +) {} diff --git a/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java b/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java new file mode 100644 index 00000000..776825f9 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java @@ -0,0 +1,19 @@ +package com.emobile.springtodo.dto; + +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import jakarta.validation.constraints.NotBlank; + +@Data +@AllArgsConstructor +public class TaskCreateRequest { + + @NotBlank(message = "Title is required") + @Size(min = 1, max = 255, message = "Title must be between 1 and 255 characters") + private String title; + + @NotBlank(message = "Description is required") + @Size(max = 2000, message = "Description must not exceed 2000 characters") + private String description; +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/dto/TaskResponse.java b/src/main/java/com/emobile/springtodo/dto/TaskResponse.java new file mode 100644 index 00000000..28c28a1e --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/TaskResponse.java @@ -0,0 +1,42 @@ +package com.emobile.springtodo.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TaskResponse { + @Positive(message = "id must be positive") + @NotNull + private Long id; + + @NotNull + private String title; + @NotNull + + private String description; + + @NotNull + private Boolean completed; + + @NotNull + private LocalDateTime createdAt; + + @NotNull + private LocalDateTime updatedAt; + + public TaskResponse(Long id, String title, String description, Boolean completed) { + this.id = id; + this.title = title; + this.description = description; + this.completed = completed; + } +} diff --git a/src/main/java/com/emobile/springtodo/dto/TaskUpdateRequest.java b/src/main/java/com/emobile/springtodo/dto/TaskUpdateRequest.java new file mode 100644 index 00000000..c74139b8 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/TaskUpdateRequest.java @@ -0,0 +1,16 @@ +package com.emobile.springtodo.dto; + +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class TaskUpdateRequest { + + @Size(min = 1, max = 255, message = "Title must be between 1 and 255 characters") + private String title; + + @Size(max = 2000, message = "Description must not exceed 2000 characters") + private String description; + + private Boolean completed; +} diff --git a/src/main/java/com/emobile/springtodo/exceptions/TaskNotFoundException.java b/src/main/java/com/emobile/springtodo/exceptions/TaskNotFoundException.java new file mode 100644 index 00000000..0d0de79b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exceptions/TaskNotFoundException.java @@ -0,0 +1,7 @@ +package com.emobile.springtodo.exceptions; + +public class TaskNotFoundException extends RuntimeException { + public TaskNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/emobile/springtodo/model/Task.java b/src/main/java/com/emobile/springtodo/model/Task.java new file mode 100644 index 00000000..50bac2ce --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/Task.java @@ -0,0 +1,27 @@ +package com.emobile.springtodo.model; + +import lombok.*; +import org.springframework.data.annotation.Id; + +import java.time.LocalDateTime; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class Task { + @Id + private Long id; + private String title; + private String description; + private boolean completed; + private LocalDateTime created_at; + private LocalDateTime updated_at; + + public Task(Long id, String description, String title, boolean completed) { + this.id = id; + this.description = description; + this.title = title; + this.completed = completed; + } +} diff --git a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java new file mode 100644 index 00000000..9ec99f16 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java @@ -0,0 +1,108 @@ +package com.emobile.springtodo.repositories; + +import com.emobile.springtodo.exceptions.TaskNotFoundException; +import com.emobile.springtodo.model.Task; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class JdbcTemplateTaskDaoImpl implements TaskDao { + + private final JdbcTemplate jdbcTemplate; + + @Override + public Optional> findAllWithPagination(int limit, int offset) { + String SQL = "SELECT * FROM tasks ORDER BY id LIMIT ? OFFSET ?"; + try { + List tasks = jdbcTemplate.query(SQL, new RowMapperImpl(), limit, offset); + log.info("repo task = {}", tasks); + return Optional.of(tasks); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + @Override + public Optional findById(Long id) { + String SQL = "SELECT * FROM tasks WHERE id = ?"; + try { + Task task = jdbcTemplate.queryForObject(SQL, new RowMapperImpl(), id); + return Optional.ofNullable(task); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + @Override + public Optional> findAllByStatus(boolean isCompleted) { + String SQL = "SELECT * FROM tasks WHERE completed = ?"; + try { + List tasks = jdbcTemplate.query(SQL, new RowMapperImpl(), isCompleted); + log.info("tasks with status {} : {}", isCompleted, tasks); + return Optional.of(tasks); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + @Override + public Integer countTasks() { + String SQL = "SELECT COUNT(*) FROM tasks"; + Integer count = jdbcTemplate.queryForObject(SQL, Integer.class); + return (count != null) ? count : 0; + } + + @Override + public Task save(Task task) { + String sql = """ + INSERT INTO tasks (title, description, completed, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) RETURNING id + """; + Long id = jdbcTemplate.queryForObject( + sql, Long.class, + task.getTitle(), + task.getDescription(), + task.isCompleted(), + java.sql.Timestamp.valueOf(task.getCreated_at()), + java.sql.Timestamp.valueOf(task.getUpdated_at()) + ); + task.setId(id); + return task; + } + + @Override + public void delete(Task task) { + String SQL = "DELETE FROM tasks WHERE id = ?"; + jdbcTemplate.update(SQL, task.getId()); + } + + @Override + public Task update(Long id, Task task) { + String sql = "UPDATE tasks " + + "SET title = ?, description = ?, completed = ?, updated_at = ? " + + "WHERE id = ?"; + int updated = jdbcTemplate.update( + sql, + task.getTitle(), + task.getDescription(), + task.isCompleted(), + java.sql.Timestamp.valueOf(task.getUpdated_at()), + id + ); + + if (updated == 0) { + throw new TaskNotFoundException("Task not found with id " + id); + } + + task.setId(id); + return task; + } +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/repositories/RowMapperImpl.java b/src/main/java/com/emobile/springtodo/repositories/RowMapperImpl.java new file mode 100644 index 00000000..b6fcb75a --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repositories/RowMapperImpl.java @@ -0,0 +1,22 @@ +package com.emobile.springtodo.repositories; + +import com.emobile.springtodo.model.Task; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +public class RowMapperImpl implements RowMapper { + + @Override + public Task mapRow(ResultSet rs, int rowNum) throws SQLException { + Task task = new Task(); + task.setId(rs.getLong("id")); + task.setTitle(rs.getString("title")); + task.setDescription(rs.getString("description")); + task.setCreated_at(rs.getTimestamp("created_at").toLocalDateTime()); + task.setUpdated_at(rs.getTimestamp("updated_at").toLocalDateTime()); + task.setCompleted(rs.getBoolean("completed")); + return task; + } +} diff --git a/src/main/java/com/emobile/springtodo/repositories/TaskDao.java b/src/main/java/com/emobile/springtodo/repositories/TaskDao.java new file mode 100644 index 00000000..64693dc8 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repositories/TaskDao.java @@ -0,0 +1,17 @@ +package com.emobile.springtodo.repositories; + +import com.emobile.springtodo.model.Task; + +import java.util.List; +import java.util.Optional; + + +public interface TaskDao { + Optional> findAllWithPagination(int limit, int offset); + Optional findById(Long id); + Task save(Task task); + void delete(Task task); + Task update(Long id, Task task); + Optional> findAllByStatus(boolean isCompleted); + Integer countTasks(); +} diff --git a/src/main/java/com/emobile/springtodo/services/MetricsService.java b/src/main/java/com/emobile/springtodo/services/MetricsService.java new file mode 100644 index 00000000..00733351 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/services/MetricsService.java @@ -0,0 +1,39 @@ +package com.emobile.springtodo.services; + +import com.emobile.springtodo.model.Task; +import io.micrometer.core.instrument.*; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MetricsService { + private final MeterRegistry meterRegistry; + private final TaskServiceInterface taskService; + + @PostConstruct + public void init() { + initializeGauges(); + } + + private void initializeGauges() { + Gauge.builder("todo.tasks.active.count", taskService, service -> service.countTaskByStatus(false)) + .description("Current number of active tasks") + .tag("application", "todo-service") + .register(meterRegistry); + + Gauge.builder("todo.tasks.completed.total", taskService, service -> service.countTaskByStatus(true)) + .description("Total number of completed tasks (gauge)") + .tag("application", "todo-service") + .register(meterRegistry); + } +} diff --git a/src/main/java/com/emobile/springtodo/services/TaskService.java b/src/main/java/com/emobile/springtodo/services/TaskService.java new file mode 100644 index 00000000..ddb8978d --- /dev/null +++ b/src/main/java/com/emobile/springtodo/services/TaskService.java @@ -0,0 +1,111 @@ +package com.emobile.springtodo.services; + +import com.emobile.springtodo.dto.PaginatedResult; +import com.emobile.springtodo.dto.TaskCreateRequest; +import com.emobile.springtodo.dto.TaskResponse; +import com.emobile.springtodo.dto.TaskUpdateRequest; +import com.emobile.springtodo.exceptions.TaskNotFoundException; +import com.emobile.springtodo.model.Task; +import com.emobile.springtodo.repositories.TaskDao; +import com.emobile.springtodo.utils.TaskMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@Slf4j +@RequiredArgsConstructor +public class TaskService implements TaskServiceInterface { + private final TaskDao taskRepository; + private final TaskMapper taskMapper; + + @Override + @Cacheable(value = "tasks", key = "'all'", sync = true) + public PaginatedResult getAllTasks(int limit, int offset) { + Optional> tasksOptional = taskRepository.findAllWithPagination(limit, offset); + List taskResponses = tasksOptional.orElse(List.of()) + .stream() + .map(taskMapper::toResponse) + .toList(); + + int totalCount = taskRepository.countTasks(); + + return new PaginatedResult<>(taskResponses, totalCount); + } + + @Override + @Cacheable(value = "task", key = "#taskId", sync = true) + public TaskResponse get(Long taskId) { + Task task = taskRepository.findById(taskId) + .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + taskId)); + return taskMapper.toResponse(task); + } + + @Transactional + @Caching( + put = @CachePut(value = "task", key = "#result.id"), + evict = { + @CacheEvict(value = "tasks", allEntries = true), + @CacheEvict(value = "tasks_completed", allEntries = true), + @CacheEvict(value = "tasks_active", allEntries = true) + } + ) + @Override + public TaskResponse create(TaskCreateRequest taskCreateRequest) { + Task task = taskMapper.toEntity(taskCreateRequest); + Task newTask = taskRepository.save(task); + return taskMapper.toResponse(newTask); + } + + @Transactional + @Caching( + put = @CachePut(value = "task", key = "#id"), + evict = { + @CacheEvict(value = "tasks", allEntries = true), + @CacheEvict(value = "tasks_completed", allEntries = true), + @CacheEvict(value = "tasks_active", allEntries = true) + } + ) + @Override + public TaskResponse updateTask(Long id, TaskUpdateRequest taskDetails) { + log.info("Updating task with id: {}", id); + Task task = taskRepository.findById(id) + .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + id)); + + taskMapper.updateEntityFromRequest(taskDetails, task); + Task updatedTask = taskRepository.update(id, task); + + log.info("Task updated successfully: {}", id); + return taskMapper.toResponse(updatedTask); + + } + + @Transactional + @Caching(evict = { + @CacheEvict(value = "task", key = "#id"), + @CacheEvict(value = "tasks", allEntries = true), + @CacheEvict(value = "tasks_completed", allEntries = true), + @CacheEvict(value = "tasks_active", allEntries = true) + }) + @Override + public void deleteTask(Long id) { + Task task = taskRepository.findById(id) + .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + id)); + taskRepository.delete(task); + } + + @Override + public int countTaskByStatus(boolean isCompleted) { + List tasks = taskRepository.findAllByStatus(isCompleted) + .orElseThrow(() -> new TaskNotFoundException("Task not found with status " + isCompleted)); + return tasks.size(); + } +} diff --git a/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java b/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java new file mode 100644 index 00000000..9a335a31 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java @@ -0,0 +1,29 @@ +package com.emobile.springtodo.services; + +import com.emobile.springtodo.dto.PaginatedResult; +import com.emobile.springtodo.dto.TaskCreateRequest; +import com.emobile.springtodo.dto.TaskResponse; +import com.emobile.springtodo.dto.TaskUpdateRequest; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Caching; + +import java.util.List; + +public interface TaskServiceInterface { + PaginatedResult getAllTasks(int limit, int offset); + TaskResponse get(Long taskId); + + TaskResponse create(TaskCreateRequest taskCreateRequest); + + @Caching(evict = { + @CacheEvict(value = "task", key = "#id"), + @CacheEvict(value = "tasks", allEntries = true), + @CacheEvict(value = "tasks_completed", allEntries = true), + @CacheEvict(value = "tasks_active", allEntries = true) + }) + TaskResponse updateTask(Long id, TaskUpdateRequest taskDetails); + + int countTaskByStatus(boolean isCompleted); + + void deleteTask(Long id); +} diff --git a/src/main/java/com/emobile/springtodo/utils/TaskMapper.java b/src/main/java/com/emobile/springtodo/utils/TaskMapper.java new file mode 100644 index 00000000..64a8609f --- /dev/null +++ b/src/main/java/com/emobile/springtodo/utils/TaskMapper.java @@ -0,0 +1,40 @@ +package com.emobile.springtodo.utils; +import com.emobile.springtodo.dto.TaskCreateRequest; +import com.emobile.springtodo.dto.TaskResponse; +import com.emobile.springtodo.dto.TaskUpdateRequest; +import com.emobile.springtodo.model.Task; +import org.mapstruct.*; +import org.mapstruct.factory.Mappers; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface TaskMapper { + + // Маппинг из Entity в ResponseDTO + @Mapping(source = "created_at", target = "createdAt") + @Mapping(source = "updated_at", target = "updatedAt") + TaskResponse toResponse(Task task); + + List toResponseList(List tasks); + + // Маппинг из CreateRequest DTO в Entity + @Mapping(target = "id", ignore = true) + @Mapping(target = "completed", constant = "false") + @Mapping(target = "created_at", expression = "java(java.time.LocalDateTime.now())") + @Mapping(target = "updated_at", expression = "java(java.time.LocalDateTime.now())") + Task toEntity(TaskCreateRequest request); + + // Маппинг из UpdateRequest DTO в Entity (обновление существующей сущности) + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + @Mapping(target = "id", ignore = true) + @Mapping(target = "created_at", ignore = true) + @Mapping(target = "updated_at", expression = "java(java.time.LocalDateTime.now())") + void updateEntityFromRequest(TaskUpdateRequest request, @MappingTarget Task task); + + // Маппинг для пагинации + default Page toResponsePage(Page taskPage) { + return taskPage.map(this::toResponse); + } +} 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..ea1e0580 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,33 @@ +server: + port: 8082 + +logging: + level: + org: + hibernate: + SQL: DEBUG +#logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE + +spring: + redis: + host: localhost + port: 6379 + password: Qwerty123 + database: 0 + embedded: false + cache: + type: redis + time-to-live: 3600000 + cache-null-values: false + application: + name: SpringToDo + datasource: + url: jdbc:postgresql://localhost:5432/postgres + username: postgres + password: postgres + driver-class-name: org.postgresql.Driver +management: + endpoints: + web: + exposure: + include: health,metrics,info,prometheus \ No newline at end of file diff --git a/src/main/resources/db/changelog/001-create-table.yaml b/src/main/resources/db/changelog/001-create-table.yaml new file mode 100644 index 00000000..9f234cc6 --- /dev/null +++ b/src/main/resources/db/changelog/001-create-table.yaml @@ -0,0 +1,44 @@ +databaseChangeLog: + - changeSet: + id: 1 + author: yuzhakovmikhail + changes: + - createTable: + tableName: tasks + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: title + type: VARCHAR(255) + constraints: + nullable: false + unique: false + - column: + name: description + type: VARCHAR(2000) + constraints: + nullable: false + unique: false + - column: + name: completed + type: BOOLEAN + constraints: + nullable: false + - column: + name: created_at + type: DATE + constraints: + nullable: false + remarks: Дата создание задачи + - column: + name: updated_at + type: DATE + constraints: + nullable: false + remarks: Дата обновления задачи \ No newline at end of file diff --git a/src/main/resources/db/changelog/002-fill-table.yaml b/src/main/resources/db/changelog/002-fill-table.yaml new file mode 100644 index 00000000..db1c1e86 --- /dev/null +++ b/src/main/resources/db/changelog/002-fill-table.yaml @@ -0,0 +1,89 @@ +databaseChangeLog: + - changeSet: + id: 2 + author: yuzhakovmikhail + changes: + - insert: + tableName: tasks + columns: + - column: + name: id + autoIncrement: "true" + - column: + name: title + value: "Новая задача № 1" + - column: + name: description + value: "Необходимо организовать..." + - column: + name: completed + value: "false" + - column: + name: created_at + value: "2025-05-25" + - column: + name: updated_at + value: "2025-06-25" + - insert: + tableName: tasks + columns: + - column: + name: id + autoIncrement: "true" + - column: + name: title + value: "Новая задача № 2" + - column: + name: description + value: "Необходимо спланировать..." + - column: + name: completed + value: "false" + - column: + name: created_at + value: "2024-05-25" + - column: + name: updated_at + value: "2024-06-25" + - insert: + tableName: tasks + columns: + - column: + name: id + autoIncrement: "true" + - column: + name: title + value: "Новая задача № 3" + - column: + name: description + value: "Необходимо удалить..." + - column: + name: completed + value: "true" + - column: + name: created_at + value: "2025-07-28" + - column: + name: updated_at + value: "2025-07-30" + - insert: + tableName: tasks + columns: + - column: + name: id + autoIncrement: "true" + - column: + name: title + value: "Новая задача № 4" + - column: + name: description + value: "Необходимо обновить..." + - column: + name: completed + value: "false" + - column: + name: created_at + value: "2025-07-23" + - column: + name: updated_at + value: "2025-07-27" \ 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..769e975e --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,5 @@ +databaseChangeLog: + - include: + file: db/changelog/001-create-table.yaml + - include: + file: db/changelog/002-fill-table.yaml \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/DatabaseTestBase.java b/src/test/java/com/emobile/springtodo/DatabaseTestBase.java new file mode 100644 index 00000000..15bfc3d1 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/DatabaseTestBase.java @@ -0,0 +1,27 @@ +package com.emobile.springtodo; + +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +@Transactional +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public abstract class DatabaseTestBase { + + @Container + private static final PostgreSQLContainer postgresContainer = + new PostgreSQLContainer<>("postgres:15-alpine") + .withReuse(true); + + @DynamicPropertySource + static void postgresProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + } +} diff --git a/src/test/java/com/emobile/springtodo/IntegrationTestBase.java b/src/test/java/com/emobile/springtodo/IntegrationTestBase.java new file mode 100644 index 00000000..56887787 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/IntegrationTestBase.java @@ -0,0 +1,8 @@ +package com.emobile.springtodo; + +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@AutoConfigureMockMvc +public abstract class IntegrationTestBase extends DatabaseTestBase {} diff --git a/src/test/java/com/emobile/springtodo/controller/TaskControllerTest.java b/src/test/java/com/emobile/springtodo/controller/TaskControllerTest.java new file mode 100644 index 00000000..0933d9cd --- /dev/null +++ b/src/test/java/com/emobile/springtodo/controller/TaskControllerTest.java @@ -0,0 +1,107 @@ +package com.emobile.springtodo.controller; +import com.emobile.springtodo.IntegrationTestBase; +import com.emobile.springtodo.dto.TaskCreateRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +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.http.MediaType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class TaskControllerTest extends IntegrationTestBase { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("GET /api/tasks - Успешное получение списка задач") + @Sql("/sql/add-tasks.sql") + void getAllTasks_shouldReturnListOfTasks() throws Exception { + // Act + MvcResult mvcResult = mockMvc.perform(get("/api/tasks?limit=2&offset=0")) + .andExpect(status().isOk()) + .andReturn(); + + // Assert + String actualJsonResponse = mvcResult.getResponse().getContentAsString(); + + String expectedJsonResponse = """ + { + "success": true, + "data": [ + { + "id": 1, + "title": "First Task", + "description": "Description for first task", + "completed": false + }, + { + "id": 2, + "title": "Second Task", + "description": "Description for second task", + "completed": true + } + ] + } + """; + + // Используем строгий режим (true), так как мы описали всю структуру + JSONAssert.assertEquals(expectedJsonResponse, actualJsonResponse, false); + } + + @Test + @DisplayName("GET /api/tasks/{id} - Успешное получение задачи по ID") + @Sql("/sql/add-tasks.sql") + void getTaskById_shouldReturnTask_whenExists() throws Exception { + MvcResult mvcResult = mockMvc.perform(get("/api/tasks/1")) + .andExpect(status().isOk()) + .andReturn(); + + String actualJsonResponse = mvcResult.getResponse().getContentAsString(); + String expectedJsonResponse = """ + { + "success": true, + "data": { "id": 1, "title": "First Task", "description": "Description for first task", "completed": false } + } + """; + JSONAssert.assertEquals(expectedJsonResponse, actualJsonResponse, false); + } + + @Test + @DisplayName("POST /api/tasks - Успешное создание задачи") + void createTask_shouldCreateAndReturnNewTask() throws Exception { + // Arrange + TaskCreateRequest request = new TaskCreateRequest("New shiny task", "Created from test"); + String requestJson = objectMapper.writeValueAsString(request); + + // Act & Assert + mockMvc.perform(post("/api/tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").isNumber()) // Проверяем, что ID сгенерирован + .andExpect(jsonPath("$.data.title").value("New shiny task")) + .andExpect(jsonPath("$.data.completed").value(false)); + } + + @Test + @DisplayName("DELETE /api/tasks/{id} - Успешное удаление задачи") + @Sql("/sql/add-tasks.sql") + void deleteTask_shouldReturnSuccessMessage() throws Exception { + mockMvc.perform(delete("/api/tasks/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value("Task delete successfully")); + } +} \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/dao/TaskDaoTest.java b/src/test/java/com/emobile/springtodo/dao/TaskDaoTest.java new file mode 100644 index 00000000..0ac1dd6a --- /dev/null +++ b/src/test/java/com/emobile/springtodo/dao/TaskDaoTest.java @@ -0,0 +1,55 @@ +package com.emobile.springtodo.dao; + +import com.emobile.springtodo.DatabaseTestBase; +import com.emobile.springtodo.model.Task; +import com.emobile.springtodo.repositories.JdbcTemplateTaskDaoImpl; +import com.emobile.springtodo.repositories.TaskDao; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class TaskDaoTest extends DatabaseTestBase { + + @Autowired + private TaskDao taskDao; + + @Test + @DisplayName("findById должен найти и вернуть существующую задачу") + @Sql("/sql/add-tasks.sql") + void findById_shouldReturnTask_whenTaskExists() { + // Act + Optional foundTask = taskDao.findById(1L); + + // Assert + assertThat(foundTask).isPresent(); + assertThat(foundTask.get().getTitle()).isEqualTo("First Task"); + assertThat(foundTask.get().isCompleted()).isFalse(); + } + + @Test + @DisplayName("findAllWithPagination должен вернуть правильную страницу задач") + @Sql("/sql/add-tasks.sql") + void findAllWithPagination_shouldReturnCorrectPage() { + // Act + var tasks = taskDao.findAllWithPagination(2, 0).orElseThrow(); + + // Assert + assertThat(tasks).hasSize(2); + assertThat(tasks.get(0).getId()).isEqualTo(1L); + assertThat(tasks.get(1).getId()).isEqualTo(2L); + } +} diff --git a/src/test/java/com/emobile/springtodo/service/TaskServiceTest.java b/src/test/java/com/emobile/springtodo/service/TaskServiceTest.java new file mode 100644 index 00000000..3886a59f --- /dev/null +++ b/src/test/java/com/emobile/springtodo/service/TaskServiceTest.java @@ -0,0 +1,96 @@ +package com.emobile.springtodo.service; + +import com.emobile.springtodo.dto.TaskCreateRequest; +import com.emobile.springtodo.dto.TaskResponse; +import com.emobile.springtodo.exceptions.TaskNotFoundException; +import com.emobile.springtodo.model.Task; +import com.emobile.springtodo.repositories.TaskDao; +import com.emobile.springtodo.services.TaskService; +import com.emobile.springtodo.utils.TaskMapper; +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.util.Optional; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) // Инициализирует моки +class TaskServiceTest { + + @Mock + private TaskDao taskRepository; // Мокаем зависимость + + @Mock + private TaskMapper taskMapper; // Мокаем зависимость + + @InjectMocks + private TaskService taskService; // Создаем экземпляр сервиса и внедряем в него моки + + @Test + @DisplayName("get() должен вернуть TaskResponse, если задача существует") + void get_shouldReturnTaskResponse_whenTaskExists() { + // Arrange (Подготовка) + long taskId = 1L; + Task task = new Task(taskId, "Test Task", "Description", false); + TaskResponse expectedResponse = new TaskResponse(taskId, "Test Task", "Description", false); + + when(taskRepository.findById(taskId)).thenReturn(Optional.of(task)); + when(taskMapper.toResponse(task)).thenReturn(expectedResponse); + + // Act (Действие) + TaskResponse actualResponse = taskService.get(taskId); + + // Assert (Проверка) + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getId()).isEqualTo(expectedResponse.getId()); + assertThat(actualResponse.getTitle()).isEqualTo(expectedResponse.getTitle()); + + verify(taskRepository).findById(taskId); // Проверяем, что метод был вызван + verify(taskMapper).toResponse(task); + } + + @Test + @DisplayName("get() должен выбросить TaskNotFoundException, если задача не найдена") + void get_shouldThrowException_whenTaskNotFound() { + // Arrange + long taskId = 99L; + when(taskRepository.findById(taskId)).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(TaskNotFoundException.class, () -> { + taskService.get(taskId); + }, "Должно быть выброшено исключение TaskNotFoundException"); + + verify(taskRepository).findById(taskId); + verifyNoInteractions(taskMapper); // Маппер не должен был вызываться + } + + @Test + @DisplayName("create() должен сохранить и вернуть новую задачу") + void create_shouldSaveAndReturnNewTask() { + // Arrange + TaskCreateRequest createRequest = new TaskCreateRequest("New Task", "New Desc"); + Task taskToSave = new Task(null, "New Task", "New Desc", false); + Task savedTask = new Task(1L, "New Task", "New Desc", false); + TaskResponse expectedResponse = new TaskResponse(1L, "New Task", "New Desc", false); + + when(taskMapper.toEntity(any(TaskCreateRequest.class))).thenReturn(taskToSave); + when(taskRepository.save(any(Task.class))).thenReturn(savedTask); + when(taskMapper.toResponse(any(Task.class))).thenReturn(expectedResponse); + + // Act + TaskResponse actualResponse = taskService.create(createRequest); + + // Assert + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getId()).isEqualTo(1L); + + verify(taskRepository).save(taskToSave); + } +} diff --git a/src/test/resources/sql/add-tasks.sql b/src/test/resources/sql/add-tasks.sql new file mode 100644 index 00000000..ac299bdf --- /dev/null +++ b/src/test/resources/sql/add-tasks.sql @@ -0,0 +1,11 @@ +-- noinspection SqlNoDataSourceInspectionForFile + +-- Очищаем таблицу перед вставкой, чтобы тесты были изолированы +-- (Хотя @Transactional и так это делает, это дополнительная гарантия) +TRUNCATE TABLE tasks RESTART IDENTITY; + +-- Вставляем тестовые данные +INSERT INTO tasks (title, description, completed, created_at, updated_at) VALUES +('First Task', 'Description for first task', false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), +('Second Task', 'Description for second task', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), +('Third Task', 'Description for third task', false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); \ No newline at end of file