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