diff --git a/pom.xml b/pom.xml
index 1098cdc..eaee356 100644
--- a/pom.xml
+++ b/pom.xml
@@ -38,13 +38,95 @@
org.projectlombok
lombok
- true
+ 1.18.36
+ provided
org.springframework.boot
spring-boot-starter-test
test
+
+ org.testcontainers
+ testcontainers
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+ org.testcontainers
+ postgresql
+ test
+
+
+ org.skyscreamer
+ jsonassert
+ test
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
+
+ org.postgresql
+ postgresql
+
+
+
+ org.flywaydb
+ flyway-core
+
+
+
+ org.flywaydb
+ flyway-database-postgresql
+
+
+
+ org.mapstruct
+ mapstruct
+ 1.5.5.Final
+
+
+ org.mapstruct
+ mapstruct-processor
+ 1.5.5.Final
+ provided
+
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ 2.8.7
+ compile
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ io.micrometer
+ micrometer-core
+
+
@@ -57,21 +139,30 @@
org.projectlombok
lombok
+ 1.18.36
- org.springframework.boot
- spring-boot-maven-plugin
-
-
-
- org.projectlombok
- lombok
-
-
-
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.11
+
+
+ prepare-agent
+
+ prepare-agent
+
+
+
+ report
+
+ report
+
+ test
+
+
diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/api/TodoApi.java b/src/main/java/com/daniilkhanukov/spring/myworks/api/TodoApi.java
new file mode 100644
index 0000000..3d0f07a
--- /dev/null
+++ b/src/main/java/com/daniilkhanukov/spring/myworks/api/TodoApi.java
@@ -0,0 +1,44 @@
+package com.daniilkhanukov.spring.myworks.api;
+
+
+import com.daniilkhanukov.spring.myworks.dto.TodoDto;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@Tag(name = "todo", description = "TODO API")
+public interface TodoApi {
+
+ @Operation(summary = "List todos with pagination and optional completed filter")
+ @GetMapping("/api/todos")
+ List list(@RequestParam(required = false) Boolean completed,
+ @RequestParam(defaultValue = "20") int limit,
+ @RequestParam(defaultValue = "0") int offset);
+
+ @Operation(summary = "Get todo")
+ @GetMapping("/api/todos/{id}")
+ TodoDto get(@PathVariable Long id);
+
+ @Operation(summary = "Create todo")
+ @PostMapping("/api/todos")
+ @ResponseStatus(code = HttpStatus.CREATED)
+ TodoDto create(@Valid @RequestBody TodoDto dto, HttpServletResponse response);
+
+ @Operation(summary = "Update todo")
+ @PutMapping("/api/todos/{id}")
+ TodoDto update(@PathVariable Long id, @Valid @RequestBody TodoDto dto);
+
+ @Operation(summary = "Delete todo")
+ @DeleteMapping("/api/todos/{id}")
+ @ResponseStatus(code = HttpStatus.NO_CONTENT)
+ void delete(@PathVariable Long id);
+
+ @Operation(summary = "Toggle completed")
+ @PatchMapping("/api/todos/{id}/toggle")
+ TodoDto toggle(@PathVariable Long id);
+}
diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/config/CacheConfig.java b/src/main/java/com/daniilkhanukov/spring/myworks/config/CacheConfig.java
new file mode 100644
index 0000000..3c22afe
--- /dev/null
+++ b/src/main/java/com/daniilkhanukov/spring/myworks/config/CacheConfig.java
@@ -0,0 +1,9 @@
+package com.daniilkhanukov.spring.myworks.config;
+
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@EnableCaching
+public class CacheConfig {
+}
diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/controller/TodoController.java b/src/main/java/com/daniilkhanukov/spring/myworks/controller/TodoController.java
new file mode 100644
index 0000000..53dd88b
--- /dev/null
+++ b/src/main/java/com/daniilkhanukov/spring/myworks/controller/TodoController.java
@@ -0,0 +1,60 @@
+package com.daniilkhanukov.spring.myworks.controller;
+
+import com.daniilkhanukov.spring.myworks.api.TodoApi;
+import com.daniilkhanukov.spring.myworks.dto.TodoDto;
+import com.daniilkhanukov.spring.myworks.entity.Todo;
+import com.daniilkhanukov.spring.myworks.service.TodoService;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import lombok.AllArgsConstructor;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Optional;
+
+@RestController
+public class TodoController implements TodoApi {
+
+ private final TodoService service;
+
+ public TodoController(TodoService service) {
+ this.service = service;
+ }
+
+ @Override
+ public List list(Boolean completed, int limit, int offset) {
+ return service.getAll(completed, limit, offset);
+ }
+
+ @Override
+ public TodoDto get(Long id) {
+ return service.getById(id);
+ }
+
+ @Override
+ public TodoDto create(TodoDto dto, HttpServletResponse response) {
+ TodoDto created = service.create(dto);
+ // установить Location header без ResponseEntity
+ response.setHeader("Location", "/api/todos/" + created.getId());
+ return created;
+ }
+
+ @Override
+ public TodoDto update(Long id, TodoDto dto) {
+ return service.update(id, dto);
+ }
+
+ @Override
+ public void delete(Long id) {
+ service.delete(id);
+ }
+
+
+ @Override
+ public TodoDto toggle(Long id) {
+ return service.toggle(id);
+ }
+}
diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/dto/TodoDto.java b/src/main/java/com/daniilkhanukov/spring/myworks/dto/TodoDto.java
new file mode 100644
index 0000000..f79d833
--- /dev/null
+++ b/src/main/java/com/daniilkhanukov/spring/myworks/dto/TodoDto.java
@@ -0,0 +1,24 @@
+package com.daniilkhanukov.spring.myworks.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class TodoDto {
+ private Long id;
+
+ @NotBlank(message = "Title is required")
+ @Size(max = 255, message = "Title must be at most 255 characters")
+ private String title;
+
+ private String description;
+ private Boolean completed;
+ private LocalDateTime createdAt;
+}
\ No newline at end of file
diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/entity/Todo.java b/src/main/java/com/daniilkhanukov/spring/myworks/entity/Todo.java
new file mode 100644
index 0000000..29c7a7f
--- /dev/null
+++ b/src/main/java/com/daniilkhanukov/spring/myworks/entity/Todo.java
@@ -0,0 +1,18 @@
+package com.daniilkhanukov.spring.myworks.entity;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class Todo {
+ private Long id;
+ private String title;
+ private String description;
+ private boolean completed;
+ private LocalDateTime createdAt;
+}
\ No newline at end of file
diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/exception/ApiError.java b/src/main/java/com/daniilkhanukov/spring/myworks/exception/ApiError.java
new file mode 100644
index 0000000..27b6a53
--- /dev/null
+++ b/src/main/java/com/daniilkhanukov/spring/myworks/exception/ApiError.java
@@ -0,0 +1,27 @@
+package com.daniilkhanukov.spring.myworks.exception;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ApiError {
+
+ private String error;
+ private Object details;
+
+ public ApiError(String error, Object details) {
+ this.error = error;
+ this.details = details;
+ }
+
+ public ApiError(String error) {
+ this.error = error;
+ }
+
+ public String getError() {
+ return error;
+ }
+
+ public Object getDetails() {
+ return details;
+ }
+}
diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/exception/GlobalExceptionHandler.java b/src/main/java/com/daniilkhanukov/spring/myworks/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..f7ad8c2
--- /dev/null
+++ b/src/main/java/com/daniilkhanukov/spring/myworks/exception/GlobalExceptionHandler.java
@@ -0,0 +1,40 @@
+package com.daniilkhanukov.spring.myworks.exception;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Slf4j
+@ControllerAdvice
+public class GlobalExceptionHandler {
+ @ExceptionHandler(NotFoundException.class)
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ @ResponseBody
+ public ApiError handleNotFound(NotFoundException ex) {
+ return new ApiError("Not Found", ex.getMessage());
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ @ResponseBody
+ public ApiError handleValidation(MethodArgumentNotValidException ex) {
+ Map errors = ex.getBindingResult().getFieldErrors().stream()
+ .collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
+ return new ApiError("Validation Failed", errors);
+ }
+
+ @ExceptionHandler(Exception.class)
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ @ResponseBody
+ public ApiError handleAll(Exception ex) {
+ return new ApiError("Internal Error", ex.getMessage());
+ }
+}
diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/exception/NotFoundException.java b/src/main/java/com/daniilkhanukov/spring/myworks/exception/NotFoundException.java
new file mode 100644
index 0000000..d1c8d0b
--- /dev/null
+++ b/src/main/java/com/daniilkhanukov/spring/myworks/exception/NotFoundException.java
@@ -0,0 +1,7 @@
+package com.daniilkhanukov.spring.myworks.exception;
+
+public class NotFoundException extends RuntimeException {
+ public NotFoundException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/mapper/TodoMapper.java b/src/main/java/com/daniilkhanukov/spring/myworks/mapper/TodoMapper.java
new file mode 100644
index 0000000..70058e3
--- /dev/null
+++ b/src/main/java/com/daniilkhanukov/spring/myworks/mapper/TodoMapper.java
@@ -0,0 +1,11 @@
+package com.daniilkhanukov.spring.myworks.mapper;
+
+import com.daniilkhanukov.spring.myworks.dto.TodoDto;
+import com.daniilkhanukov.spring.myworks.entity.Todo;
+import org.mapstruct.Mapper;
+
+@Mapper(componentModel = "spring")
+public interface TodoMapper {
+ TodoDto toDto(Todo todo);
+ Todo toEntity(TodoDto dto);
+}
diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepository.java b/src/main/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepository.java
new file mode 100644
index 0000000..dd5eb15
--- /dev/null
+++ b/src/main/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepository.java
@@ -0,0 +1,86 @@
+package com.daniilkhanukov.spring.myworks.repository;
+
+import com.daniilkhanukov.spring.myworks.entity.Todo;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+
+import java.sql.PreparedStatement;
+import java.sql.Statement;
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public class JdbcTodoRepository {
+
+ private final JdbcTemplate jdbcTemplate;
+
+ private final RowMapper mapper = (rs, rowNum) -> {
+ Todo t =new Todo();
+ t.setId(rs.getLong("id"));
+ t.setTitle(rs.getString("title"));
+ t.setDescription(rs.getString("description"));
+ t.setCompleted(rs.getBoolean("completed"));
+ t.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
+ return t;
+ };
+
+ public JdbcTodoRepository(JdbcTemplate jdbcTemplate) {
+ this.jdbcTemplate = jdbcTemplate;
+ }
+
+ public List findAll(int limit, int offset) {
+ String sql = "SELECT * FROM todos ORDER BY id LIMIT ? OFFSET ?";
+ return jdbcTemplate.query(sql, mapper, limit, offset);
+ }
+
+ public Optional findById(Long id) {
+ String sql = "SELECT * FROM todos WHERE id = ?";
+ List list = jdbcTemplate.query(sql, mapper, id);
+ return list.stream().findFirst();
+ }
+
+ public List findByCompleted(boolean completed, int limit, int offset) {
+ String sql = "SELECT * FROM todos WHERE completed = ? ORDER BY id LIMIT ? OFFSET ?";
+ return jdbcTemplate.query(sql, mapper, completed, limit, offset);
+ }
+
+ public Todo save(Todo t) {
+ if (t.getId() == null) {
+ String sql = """
+ INSERT INTO todos (title, description, completed)
+ VALUES (?, ?, ?)
+ RETURNING id
+ """;
+
+ Long id = jdbcTemplate.queryForObject(
+ sql,
+ Long.class,
+ t.getTitle(),
+ t.getDescription(),
+ t.isCompleted()
+ );
+
+ t.setId(id);
+ return t;
+ } else {
+ String sql = "UPDATE todos SET title = ?, description = ?, completed = ? WHERE id = ?";
+ jdbcTemplate.update(sql, t.getTitle(), t.getDescription(), t.isCompleted(), t.getId());
+ return t;
+ }
+ }
+
+ public void deleteById(Long id) {
+ jdbcTemplate.update("DELETE FROM todos WHERE id = ?", id);
+ }
+
+ public long countCompleted() {
+ Long result = jdbcTemplate.queryForObject(
+ "SELECT count(*) FROM todos WHERE completed = true",
+ Long.class
+ );
+ return result != null ? result : 0L;
+ }
+}
diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/service/TodoService.java b/src/main/java/com/daniilkhanukov/spring/myworks/service/TodoService.java
new file mode 100644
index 0000000..75f1fb0
--- /dev/null
+++ b/src/main/java/com/daniilkhanukov/spring/myworks/service/TodoService.java
@@ -0,0 +1,77 @@
+package com.daniilkhanukov.spring.myworks.service;
+
+import com.daniilkhanukov.spring.myworks.dto.TodoDto;
+import com.daniilkhanukov.spring.myworks.entity.Todo;
+import com.daniilkhanukov.spring.myworks.exception.NotFoundException;
+import com.daniilkhanukov.spring.myworks.mapper.TodoMapper;
+import com.daniilkhanukov.spring.myworks.repository.JdbcTodoRepository;
+
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.MeterRegistry;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class TodoService {
+
+ private static final String TODO_NOT_FOUND = "Todo not found with id: ";
+
+ private final JdbcTodoRepository repository;
+ private final TodoMapper mapper;
+ private final MeterRegistry meterRegistry;
+
+ @PostConstruct
+ public void initMetrics() {
+ Gauge.builder("todos.completed.count", repository, r -> r.countCompleted())
+ .description("Number of completed todos")
+ .register(meterRegistry);
+ }
+
+ @Cacheable(value = "todos", key = "{#completed, #limit, #offset}")
+ public List getAll(Boolean completed, int limit, int offset) {
+ List list = (completed == null) ? repository.findAll(limit, offset) : repository.findByCompleted(completed, limit, offset);
+ return list.stream().map(mapper::toDto).toList();
+ }
+
+ public TodoDto getById(Long id) {
+ return repository.findById(id).map(mapper::toDto).orElseThrow(() -> new NotFoundException(TODO_NOT_FOUND + id));
+ }
+
+ @CacheEvict(value = "todos", allEntries = true)
+ public TodoDto create(TodoDto dto) {
+ Todo t = mapper.toEntity(dto);
+ t.setCompleted(dto.getCompleted() != null && dto.getCompleted());
+ Todo saved = repository.save(t);
+ return mapper.toDto(saved);
+ }
+
+ @CacheEvict(value = "todos", allEntries = true)
+ public TodoDto update(Long id, TodoDto dto) {
+ Todo existing = repository.findById(id).orElseThrow(() -> new NotFoundException(TODO_NOT_FOUND + id));
+ existing.setTitle(dto.getTitle());
+ existing.setDescription(dto.getDescription());
+ if (dto.getCompleted() != null) existing.setCompleted(dto.getCompleted());
+ repository.save(existing);
+ return mapper.toDto(existing);
+ }
+
+ @CacheEvict(value = "todos", allEntries = true)
+ public void delete(Long id) {
+ repository.deleteById(id);
+ }
+
+ @CacheEvict(value = "todos", allEntries = true)
+ public TodoDto toggle(Long id) {
+ Todo t = repository.findById(id).orElseThrow(() -> new NotFoundException(TODO_NOT_FOUND + id));
+ t.setCompleted(!t.isCompleted());
+ repository.save(t);
+ return mapper.toDto(t);
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 0c6b007..c405a2f 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1 +1,21 @@
spring.application.name=MyWorks
+
+server.port=8080
+
+spring.datasource.url=jdbc:postgresql://localhost:5432/todo_db
+spring.datasource.username=todo_user
+spring.datasource.password=todo_password
+spring.datasource.driver-class-name=org.postgresql.Driver
+
+spring.flyway.enabled=true
+spring.flyway.locations=src/main/resources/db/migration/V1__init_schema.sql
+
+spring.cache.type=redis
+spring.data.redis.host=localhost
+spring.data.redis.port=6379
+
+management.endpoints.web.exposure.include=health,info,metrics
+management.endpoint.health.show-details=always
+
+springdoc.swagger-ui.path=/swagger-ui.html
+springdoc.api-docs.path=/v3/api-docs
diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql
new file mode 100644
index 0000000..330a721
--- /dev/null
+++ b/src/main/resources/db/migration/V1__init_schema.sql
@@ -0,0 +1,7 @@
+create table todos (
+ id bigserial primary key,
+ title varchar(255) not null,
+ description TEXT,
+ completed boolean not null default false,
+ created_at timestamp without time zone not null default now()
+);
\ No newline at end of file
diff --git a/src/test/java/com/daniilkhanukov/spring/myworks/controller/TodoControllerTest.java b/src/test/java/com/daniilkhanukov/spring/myworks/controller/TodoControllerTest.java
new file mode 100644
index 0000000..320a6b1
--- /dev/null
+++ b/src/test/java/com/daniilkhanukov/spring/myworks/controller/TodoControllerTest.java
@@ -0,0 +1,66 @@
+package com.daniilkhanukov.spring.myworks.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+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.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@Testcontainers
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@DisplayName("Integration tests for TodoController")
+class TodoControllerTest {
+
+ @Container
+ static PostgreSQLContainer> postgres =
+ new PostgreSQLContainer<>("postgres:15")
+ .withDatabaseName("testdb")
+ .withUsername("test")
+ .withPassword("test");
+
+ @DynamicPropertySource
+ static void overrideProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", postgres::getJdbcUrl);
+ registry.add("spring.datasource.username", postgres::getUsername);
+ registry.add("spring.datasource.password", postgres::getPassword);
+ registry.add("spring.flyway.enabled", () -> true);
+ }
+
+ @Autowired
+ MockMvc mockMvc;
+
+ @Autowired
+ ObjectMapper objectMapper;
+
+ @Test
+ @DisplayName("POST /api/todos creates todo")
+ void createTodo() throws Exception {
+
+ String json = """
+ {
+ "title": "Test todo",
+ "description": "From integration test"
+ }
+ """;
+
+ mockMvc.perform(post("/api/todos")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json))
+ .andExpect(status().isCreated());
+ }
+
+}
diff --git a/src/test/java/com/daniilkhanukov/spring/myworks/exception/NotFoundExceptionTest.java b/src/test/java/com/daniilkhanukov/spring/myworks/exception/NotFoundExceptionTest.java
new file mode 100644
index 0000000..8a381c8
--- /dev/null
+++ b/src/test/java/com/daniilkhanukov/spring/myworks/exception/NotFoundExceptionTest.java
@@ -0,0 +1,16 @@
+package com.daniilkhanukov.spring.myworks.exception;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class NotFoundExceptionTest {
+
+ @Test
+ void constructorShouldStoreMessage() {
+ NotFoundException exception = new NotFoundException("Todo not found with id: 10");
+
+ assertThat(exception)
+ .hasMessage("Todo not found with id: 10");
+ }
+}
diff --git a/src/test/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepositoryTest.java b/src/test/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepositoryTest.java
new file mode 100644
index 0000000..5791ae6
--- /dev/null
+++ b/src/test/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepositoryTest.java
@@ -0,0 +1,176 @@
+package com.daniilkhanukov.spring.myworks.repository;
+
+import com.daniilkhanukov.spring.myworks.entity.Todo;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.sql.ResultSet;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class JdbcTodoRepositoryTest {
+
+ private JdbcTemplate jdbcTemplate;
+ private JdbcTodoRepository repository;
+
+ @BeforeEach
+ void setUp() {
+ jdbcTemplate = mock(JdbcTemplate.class);
+ repository = new JdbcTodoRepository(jdbcTemplate);
+ }
+
+ @Test
+ void findAllShouldMapRows() throws Exception {
+ when(jdbcTemplate.query(anyString(), any(RowMapper.class), anyInt(), anyInt()))
+ .thenAnswer(invocation -> {
+ RowMapper rowMapper = invocation.getArgument(1);
+ ResultSet rs = mock(ResultSet.class);
+
+ when(rs.getLong("id")).thenReturn(1L);
+ when(rs.getString("title")).thenReturn("Title");
+ when(rs.getString("description")).thenReturn("Description");
+ when(rs.getBoolean("completed")).thenReturn(true);
+ when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2024, 1, 1, 10, 0)));
+
+ return List.of(rowMapper.mapRow(rs, 0));
+ });
+
+ List result = repository.findAll(10, 0);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getId()).isEqualTo(1L);
+ assertThat(result.get(0).getTitle()).isEqualTo("Title");
+ assertThat(result.get(0).isCompleted()).isTrue();
+ }
+
+ @Test
+ void findByIdShouldReturnOptionalValue() throws Exception {
+ when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq(1L)))
+ .thenAnswer(invocation -> {
+ RowMapper rowMapper = invocation.getArgument(1);
+ ResultSet rs = mock(ResultSet.class);
+
+ when(rs.getLong("id")).thenReturn(1L);
+ when(rs.getString("title")).thenReturn("Title");
+ when(rs.getString("description")).thenReturn("Description");
+ when(rs.getBoolean("completed")).thenReturn(false);
+ when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2024, 1, 1, 10, 0)));
+
+ return List.of(rowMapper.mapRow(rs, 0));
+ });
+
+ Optional result = repository.findById(1L);
+
+ assertThat(result).isPresent();
+ assertThat(result.get().getId()).isEqualTo(1L);
+ }
+
+ @Test
+ void findByCompletedShouldReturnMappedRows() throws Exception {
+ when(jdbcTemplate.query(anyString(), any(RowMapper.class), anyBoolean(), anyInt(), anyInt()))
+ .thenAnswer(invocation -> {
+ RowMapper rowMapper = invocation.getArgument(1);
+ ResultSet rs = mock(ResultSet.class);
+
+ when(rs.getLong("id")).thenReturn(2L);
+ when(rs.getString("title")).thenReturn("Done");
+ when(rs.getString("description")).thenReturn("Desc");
+ when(rs.getBoolean("completed")).thenReturn(true);
+ when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2024, 1, 1, 10, 0)));
+
+ return List.of(rowMapper.mapRow(rs, 0));
+ });
+
+ List result = repository.findByCompleted(true, 10, 0);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getTitle()).isEqualTo("Done");
+ assertThat(result.get(0).isCompleted()).isTrue();
+ }
+
+ @Test
+ void saveShouldInsertAndSetGeneratedId() {
+ Todo todo = new Todo();
+ todo.setTitle("New title");
+ todo.setDescription("New description");
+ todo.setCompleted(false);
+
+ when(jdbcTemplate.queryForObject(
+ anyString(),
+ eq(Long.class),
+ any(),
+ any(),
+ any()
+ )).thenReturn(100L);
+
+ Todo saved = repository.save(todo);
+
+ assertThat(saved.getId()).isEqualTo(100L);
+ verify(jdbcTemplate).queryForObject(
+ contains("INSERT INTO todos"),
+ eq(Long.class),
+ eq("New title"),
+ eq("New description"),
+ eq(false)
+ );
+ }
+
+ @Test
+ void saveShouldUpdateExistingTodo() {
+ Todo todo = new Todo();
+ todo.setId(10L);
+ todo.setTitle("Updated title");
+ todo.setDescription("Updated description");
+ todo.setCompleted(true);
+
+ Todo saved = repository.save(todo);
+
+ assertThat(saved.getId()).isEqualTo(10L);
+ verify(jdbcTemplate).update(
+ contains("UPDATE todos SET title = ?, description = ?, completed = ? WHERE id = ?"),
+ eq("Updated title"),
+ eq("Updated description"),
+ eq(true),
+ eq(10L)
+ );
+ }
+
+ @Test
+ void deleteByIdShouldDelegateToJdbcTemplate() {
+ repository.deleteById(55L);
+
+ verify(jdbcTemplate).update("DELETE FROM todos WHERE id = ?", 55L);
+ }
+
+ @Test
+ void countCompletedShouldReturnValue() {
+ when(jdbcTemplate.queryForObject(
+ "SELECT count(*) FROM todos WHERE completed = true",
+ Long.class
+ )).thenReturn(7L);
+
+ long result = repository.countCompleted();
+
+ assertThat(result).isEqualTo(7L);
+ }
+
+ @Test
+ void countCompletedShouldReturnZeroWhenNull() {
+ when(jdbcTemplate.queryForObject(
+ "SELECT count(*) FROM todos WHERE completed = true",
+ Long.class
+ )).thenReturn(null);
+
+ long result = repository.countCompleted();
+
+ assertThat(result).isZero();
+ }
+}
diff --git a/src/test/java/com/daniilkhanukov/spring/myworks/service/TodoServiceTest.java b/src/test/java/com/daniilkhanukov/spring/myworks/service/TodoServiceTest.java
new file mode 100644
index 0000000..92ad6c7
--- /dev/null
+++ b/src/test/java/com/daniilkhanukov/spring/myworks/service/TodoServiceTest.java
@@ -0,0 +1,209 @@
+package com.daniilkhanukov.spring.myworks.service;
+
+import com.daniilkhanukov.spring.myworks.dto.TodoDto;
+import com.daniilkhanukov.spring.myworks.entity.Todo;
+import com.daniilkhanukov.spring.myworks.exception.NotFoundException;
+import com.daniilkhanukov.spring.myworks.mapper.TodoMapper;
+import com.daniilkhanukov.spring.myworks.repository.JdbcTodoRepository;
+import io.micrometer.core.instrument.MeterRegistry;
+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.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class TodoServiceTest {
+
+ @Mock
+ private JdbcTodoRepository repository;
+
+ @Mock
+ private TodoMapper mapper;
+
+ @Mock
+ private MeterRegistry meterRegistry;
+
+ @InjectMocks
+ private TodoService service;
+
+ @Test
+ void getAllWhenCompletedIsNullShouldUseFindAll() {
+ Todo todo = todo(1L, "Title", "Description", false);
+ TodoDto dto = todoDto(1L, "Title", "Description", false);
+
+ when(repository.findAll(10, 0)).thenReturn(List.of(todo));
+ when(mapper.toDto(todo)).thenReturn(dto);
+
+ List result = service.getAll(null, 10, 0);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getId()).isEqualTo(1L);
+ verify(repository).findAll(10, 0);
+ verify(repository, never()).findByCompleted(anyBoolean(), anyInt(), anyInt());
+ }
+
+ @Test
+ void getAllWhenCompletedIsNotNullShouldUseFindByCompleted() {
+ Todo todo = todo(1L, "Title", "Description", true);
+ TodoDto dto = todoDto(1L, "Title", "Description", true);
+
+ when(repository.findByCompleted(true, 10, 0)).thenReturn(List.of(todo));
+ when(mapper.toDto(todo)).thenReturn(dto);
+
+ List result = service.getAll(true, 10, 0);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getCompleted()).isTrue();
+ verify(repository).findByCompleted(true, 10, 0);
+ verify(repository, never()).findAll(anyInt(), anyInt());
+ }
+
+ @Test
+ void getByIdShouldReturnTodoDto() {
+ Todo todo = todo(1L, "Title", "Description", false);
+ TodoDto dto = todoDto(1L, "Title", "Description", false);
+
+ when(repository.findById(1L)).thenReturn(Optional.of(todo));
+ when(mapper.toDto(todo)).thenReturn(dto);
+
+ TodoDto result = service.getById(1L);
+
+ assertThat(result.getId()).isEqualTo(1L);
+ assertThat(result.getTitle()).isEqualTo("Title");
+ }
+
+ @Test
+ void getByIdShouldThrowWhenMissing() {
+ when(repository.findById(99L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.getById(99L))
+ .isInstanceOf(NotFoundException.class)
+ .hasMessage("Todo not found with id: 99");
+ }
+
+ @Test
+ void createShouldForceCompletedFalseWhenDtoCompletedIsNull() {
+ TodoDto input = todoDto(null, "New title", "New description", null);
+ Todo entity = todo(null, "New title", "New description", true);
+ Todo saved = todo(1L, "New title", "New description", false);
+ TodoDto output = todoDto(1L, "New title", "New description", false);
+
+ when(mapper.toEntity(input)).thenReturn(entity);
+ when(repository.save(any(Todo.class))).thenReturn(saved);
+ when(mapper.toDto(saved)).thenReturn(output);
+
+ TodoDto result = service.create(input);
+
+ assertThat(result.getId()).isEqualTo(1L);
+ verify(repository).save(argThat(t -> !t.isCompleted()));
+ }
+
+ @Test
+ void createShouldRespectCompletedTrue() {
+ TodoDto input = todoDto(null, "New title", "New description", true);
+ Todo entity = todo(null, "New title", "New description", false);
+ Todo saved = todo(1L, "New title", "New description", true);
+ TodoDto output = todoDto(1L, "New title", "New description", true);
+
+ when(mapper.toEntity(input)).thenReturn(entity);
+ when(repository.save(any(Todo.class))).thenReturn(saved);
+ when(mapper.toDto(saved)).thenReturn(output);
+
+ TodoDto result = service.create(input);
+
+ assertThat(result.getCompleted()).isTrue();
+ verify(repository).save(argThat(Todo::isCompleted));
+ }
+
+ @Test
+ void updateShouldChangeTitleDescriptionAndKeepCompletedWhenDtoCompletedIsNull() {
+ Todo existing = todo(1L, "Old title", "Old description", true);
+ TodoDto input = todoDto(null, "New title", "New description", null);
+ TodoDto output = todoDto(1L, "New title", "New description", true);
+
+ when(repository.findById(1L)).thenReturn(Optional.of(existing));
+ when(repository.save(existing)).thenReturn(existing);
+ when(mapper.toDto(existing)).thenReturn(output);
+
+ TodoDto result = service.update(1L, input);
+
+ assertThat(result.getTitle()).isEqualTo("New title");
+ assertThat(result.getCompleted()).isTrue();
+ verify(repository).save(existing);
+ }
+
+ @Test
+ void updateShouldSetCompletedWhenDtoCompletedIsProvided() {
+ Todo existing = todo(1L, "Old title", "Old description", true);
+ TodoDto input = todoDto(null, "New title", "New description", false);
+ TodoDto output = todoDto(1L, "New title", "New description", false);
+
+ when(repository.findById(1L)).thenReturn(Optional.of(existing));
+ when(repository.save(existing)).thenReturn(existing);
+ when(mapper.toDto(existing)).thenReturn(output);
+
+ TodoDto result = service.update(1L, input);
+
+ assertThat(result.getCompleted()).isFalse();
+ verify(repository).save(argThat(t -> !t.isCompleted()));
+ }
+
+ @Test
+ void updateShouldThrowWhenTodoMissing() {
+ TodoDto input = todoDto(null, "New title", "New description", true);
+
+ when(repository.findById(10L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.update(10L, input))
+ .isInstanceOf(NotFoundException.class)
+ .hasMessage("Todo not found with id: 10");
+ }
+
+ @Test
+ void deleteShouldCallRepository() {
+ service.delete(5L);
+
+ verify(repository).deleteById(5L);
+ }
+
+ @Test
+ void toggleShouldInvertCompletedAndSave() {
+ Todo existing = todo(1L, "Title", "Description", false);
+ TodoDto output = todoDto(1L, "Title", "Description", true);
+
+ when(repository.findById(1L)).thenReturn(Optional.of(existing));
+ when(repository.save(existing)).thenReturn(existing);
+ when(mapper.toDto(existing)).thenReturn(output);
+
+ TodoDto result = service.toggle(1L);
+
+ assertThat(result.getCompleted()).isTrue();
+ verify(repository).save(argThat(Todo::isCompleted));
+ }
+
+ private Todo todo(Long id, String title, String description, boolean completed) {
+ Todo todo = new Todo();
+ todo.setId(id);
+ todo.setTitle(title);
+ todo.setDescription(description);
+ todo.setCompleted(completed);
+ return todo;
+ }
+
+ private TodoDto todoDto(Long id, String title, String description, Boolean completed) {
+ TodoDto dto = new TodoDto();
+ dto.setId(id);
+ dto.setTitle(title);
+ dto.setDescription(description);
+ dto.setCompleted(completed);
+ return dto;
+ }
+}
diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties
new file mode 100644
index 0000000..9d655ad
--- /dev/null
+++ b/src/test/resources/application-test.properties
@@ -0,0 +1,2 @@
+spring.flyway.locations=classpath:db/migration
+logging.level.org.flywaydb=DEBUG
\ No newline at end of file