From 29a12198102160aa108ccc1415a358302cf53b3f Mon Sep 17 00:00:00 2001 From: Daniil Khanukov Date: Tue, 13 Jan 2026 15:28:24 +0300 Subject: [PATCH 1/4] =?UTF-8?q?=D0=92=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20"Sp?= =?UTF-8?q?ring=20ToDo"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 50 +++++++++---- .../myworks/controller/TodoController.java | 59 ++++++++++++++++ .../spring/myworks/dto/TodoDto.java | 23 ++++++ .../spring/myworks/entity/Todo.java | 38 ++++++++++ .../myworks/exception/NotFoundException.java | 7 ++ .../myworks/repository/TodoRepository.java | 12 ++++ .../spring/myworks/service/TodoService.java | 70 +++++++++++++++++++ src/main/resources/application.properties | 12 ++++ 8 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/daniilkhanukov/spring/myworks/controller/TodoController.java create mode 100644 src/main/java/com/daniilkhanukov/spring/myworks/dto/TodoDto.java create mode 100644 src/main/java/com/daniilkhanukov/spring/myworks/entity/Todo.java create mode 100644 src/main/java/com/daniilkhanukov/spring/myworks/exception/NotFoundException.java create mode 100644 src/main/java/com/daniilkhanukov/spring/myworks/repository/TodoRepository.java create mode 100644 src/main/java/com/daniilkhanukov/spring/myworks/service/TodoService.java diff --git a/pom.xml b/pom.xml index 1098cdc..57b616a 100644 --- a/pom.xml +++ b/pom.xml @@ -38,13 +38,37 @@ org.projectlombok lombok - true + + 1.18.36 + provided org.springframework.boot spring-boot-starter-test test + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + + com.h2database + h2 + runtime + + + + @@ -52,18 +76,18 @@ org.apache.maven.plugins maven-compiler-plugin - - - - org.projectlombok - lombok - - - - - - org.springframework.boot - spring-boot-maven-plugin + + + + + + + + + + + + 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..a6d0f17 --- /dev/null +++ b/src/main/java/com/daniilkhanukov/spring/myworks/controller/TodoController.java @@ -0,0 +1,59 @@ +package com.daniilkhanukov.spring.myworks.controller; + +import com.daniilkhanukov.spring.myworks.dto.TodoDto; +import com.daniilkhanukov.spring.myworks.entity.Todo; +import com.daniilkhanukov.spring.myworks.service.TodoService; +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 +@RequestMapping("/api/todos") +//@RequiredArgsConstructor +//@AllArgsConstructor +public class TodoController { + + private final TodoService service; + + public TodoController(TodoService service) { + this.service = service; + } + + @GetMapping + public ResponseEntity> list(@RequestParam Optional completed) { + return ResponseEntity.ok(service.getAll(completed)); + } + + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable Long id) { + return ResponseEntity.ok(service.getById(id)); + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody TodoDto dto) { + TodoDto created = service.create(dto); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, @Valid @RequestBody TodoDto dto) { + return ResponseEntity.ok(service.update(id, dto)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + service.delete(id); + return ResponseEntity.noContent().build(); + } + + @PatchMapping("/{id}/toggle") + public ResponseEntity toggle(@PathVariable Long id) { + return ResponseEntity.ok(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..a20349f --- /dev/null +++ b/src/main/java/com/daniilkhanukov/spring/myworks/dto/TodoDto.java @@ -0,0 +1,23 @@ +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; + +@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; +} 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..128f730 --- /dev/null +++ b/src/main/java/com/daniilkhanukov/spring/myworks/entity/Todo.java @@ -0,0 +1,38 @@ +package com.daniilkhanukov.spring.myworks.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "todos") +@Getter +@Setter +@NoArgsConstructor +public class Todo { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "completed", nullable = false) + private boolean completed = false; + + @Column(name = "createdAt", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + public Todo(String title, String description) { + this.title = title; + this.description = description; + } +} 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/repository/TodoRepository.java b/src/main/java/com/daniilkhanukov/spring/myworks/repository/TodoRepository.java new file mode 100644 index 0000000..1a5a70a --- /dev/null +++ b/src/main/java/com/daniilkhanukov/spring/myworks/repository/TodoRepository.java @@ -0,0 +1,12 @@ +package com.daniilkhanukov.spring.myworks.repository; + +import com.daniilkhanukov.spring.myworks.entity.Todo; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface TodoRepository extends JpaRepository { + List findByCompleted(boolean completed); +} 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..72b2446 --- /dev/null +++ b/src/main/java/com/daniilkhanukov/spring/myworks/service/TodoService.java @@ -0,0 +1,70 @@ +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.repository.TodoRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class TodoService { + + private final TodoRepository repository; + + public List getAll(Optional completed) { + List list = completed.map(repository::findByCompleted).orElseGet(repository::findAll); + return list.stream().map(this::toDto).collect(Collectors.toList()); + } + + public TodoDto getById(Long id) { + Todo dto = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); + return toDto(dto); + } + + public TodoDto create(TodoDto dto) { + Todo todo = new Todo(); + todo.setTitle(dto.getTitle()); + todo.setDescription(dto.getDescription()); + todo.setCompleted(dto.getCompleted() != null ? dto.getCompleted() : false); + Todo saved = repository.save(todo); + return toDto(saved); + } + + public TodoDto update(Long id, TodoDto dto) { + Todo todo = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); + todo.setTitle(dto.getTitle()); + todo.setDescription(dto.getDescription()); + if (dto.getCompleted() != null) { + todo.setCompleted(dto.getCompleted()); + } + Todo saved = repository.save(todo); + return toDto(saved); + } + + public void delete(Long id) { + if (!repository.existsById(id)) throw new NotFoundException("Todo not found with id: " + id); + repository.deleteById(id); + } + + public TodoDto toggle(Long id) { + Todo todo = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); + todo.setCompleted(!todo.isCompleted()); + return toDto(repository.save(todo)); + } + + private TodoDto toDto(Todo todo) { + TodoDto dto = new TodoDto(); + dto.setId(todo.getId()); + dto.setTitle(todo.getTitle()); + dto.setDescription(todo.getDescription()); + dto.setCompleted(todo.isCompleted()); + return dto; + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0c6b007..2a35477 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,13 @@ spring.application.name=MyWorks + +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true + +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console From 45e773bbd17266e9075ba9430096682047e35d29 Mon Sep 17 00:00:00 2001 From: Daniil Khanukov Date: Tue, 28 Apr 2026 04:54:31 +0300 Subject: [PATCH 2/4] =?UTF-8?q?=D0=9D=D0=B5=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5,=20=D0=BD=D0=BE=20=D0=BF=D1=83=D1=81=D1=82=D1=8C?= =?UTF-8?q?=20=D0=B1=D1=83=D0=B4=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 67 ++++++++++- .../spring/myworks/api/TodoApi.java | 44 ++++++++ .../spring/myworks/config/CacheConfig.java | 9 ++ .../myworks/controller/TodoController.java | 69 ++++++++---- .../spring/myworks/dto/TodoDto.java | 7 +- .../spring/myworks/entity/Todo.java | 26 +---- .../spring/myworks/exception/ApiError.java | 27 +++++ .../exception/GlobalExceptionHandler.java | 40 +++++++ .../spring/myworks/mapper/TodoMapper.java | 11 ++ .../repository/JdbcTodoRepository.java | 77 +++++++++++++ .../myworks/repository/TodoRepository.java | 12 -- .../spring/myworks/service/TodoService.java | 104 ++++++++++++------ src/main/resources/application.properties | 26 +++-- .../db/migration/V1__init_schema.sql | 7 ++ .../controller/TodoControllerTest.java | 66 +++++++++++ 15 files changed, 486 insertions(+), 106 deletions(-) create mode 100644 src/main/java/com/daniilkhanukov/spring/myworks/api/TodoApi.java create mode 100644 src/main/java/com/daniilkhanukov/spring/myworks/config/CacheConfig.java create mode 100644 src/main/java/com/daniilkhanukov/spring/myworks/exception/ApiError.java create mode 100644 src/main/java/com/daniilkhanukov/spring/myworks/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/daniilkhanukov/spring/myworks/mapper/TodoMapper.java create mode 100644 src/main/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepository.java delete mode 100644 src/main/java/com/daniilkhanukov/spring/myworks/repository/TodoRepository.java create mode 100644 src/main/resources/db/migration/V1__init_schema.sql create mode 100644 src/test/java/com/daniilkhanukov/spring/myworks/controller/TodoControllerTest.java diff --git a/pom.xml b/pom.xml index 57b616a..c048717 100644 --- a/pom.xml +++ b/pom.xml @@ -47,6 +47,29 @@ 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 @@ -54,20 +77,54 @@ org.springframework.boot - spring-boot-starter-data-jpa + spring-boot-starter-validation org.springframework.boot - spring-boot-starter-validation + spring-boot-starter-data-redis - com.h2database - h2 - runtime + org.springframework.boot + spring-boot-starter-jdbc + + + org.postgresql + postgresql + + org.flywaydb + flyway-core + + + + org.mapstruct + mapstruct + 1.5.5.Final + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + provided + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 3.0.1 + compile + + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-core + 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 index a6d0f17..985ed29 100644 --- a/src/main/java/com/daniilkhanukov/spring/myworks/controller/TodoController.java +++ b/src/main/java/com/daniilkhanukov/spring/myworks/controller/TodoController.java @@ -1,8 +1,10 @@ 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; @@ -14,10 +16,10 @@ import java.util.Optional; @RestController -@RequestMapping("/api/todos") +//@RequestMapping("/api/todos") //@RequiredArgsConstructor //@AllArgsConstructor -public class TodoController { +public class TodoController implements TodoApi { private final TodoService service; @@ -25,35 +27,62 @@ public TodoController(TodoService service) { this.service = service; } - @GetMapping - public ResponseEntity> list(@RequestParam Optional completed) { - return ResponseEntity.ok(service.getAll(completed)); +// @GetMapping +// public ResponseEntity> list(@RequestParam Optional completed) { +// return ResponseEntity.ok(service.getAll(completed)); +// } + @Override + public List list(Boolean completed, int limit, int offset) { + return service.getAll(completed, limit, offset); } - @GetMapping("/{id}") - public ResponseEntity get(@PathVariable Long id) { - return ResponseEntity.ok(service.getById(id)); +// @GetMapping("/{id}") +// public ResponseEntity get(@PathVariable Long id) { +// return ResponseEntity.ok(service.getById(id)); +// } + @Override + public TodoDto get(Long id) { + return service.getById(id); } - @PostMapping - public ResponseEntity create(@Valid @RequestBody TodoDto dto) { + // @PostMapping +// public ResponseEntity create(@Valid @RequestBody TodoDto dto) { +// TodoDto created = service.create(dto); +// return ResponseEntity.status(HttpStatus.CREATED).body(created); +// } + @Override + public TodoDto create(TodoDto dto, HttpServletResponse response) { TodoDto created = service.create(dto); - return ResponseEntity.status(HttpStatus.CREATED).body(created); + // установить Location header без ResponseEntity + response.setHeader("Location", "/api/todos/" + created.getId()); + return created; } - @PutMapping("/{id}") - public ResponseEntity update(@PathVariable Long id, @Valid @RequestBody TodoDto dto) { - return ResponseEntity.ok(service.update(id, dto)); +// @PutMapping("/{id}") +// public ResponseEntity update(@PathVariable Long id, @Valid @RequestBody TodoDto dto) { +// return ResponseEntity.ok(service.update(id, dto)); +// } + @Override + public TodoDto update(Long id, TodoDto dto) { + return service.update(id, dto); } - @DeleteMapping("/{id}") - public ResponseEntity delete(@PathVariable Long id) { +// @DeleteMapping("/{id}") +// public ResponseEntity delete(@PathVariable Long id) { +// service.delete(id); +// return ResponseEntity.noContent().build(); +// } + @Override + public void delete(Long id) { service.delete(id); - return ResponseEntity.noContent().build(); } - @PatchMapping("/{id}/toggle") - public ResponseEntity toggle(@PathVariable Long id) { - return ResponseEntity.ok(service.toggle(id)); +// @PatchMapping("/{id}/toggle") +// public ResponseEntity toggle(@PathVariable Long id) { +// return ResponseEntity.ok(service.toggle(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 index a20349f..f79d833 100644 --- a/src/main/java/com/daniilkhanukov/spring/myworks/dto/TodoDto.java +++ b/src/main/java/com/daniilkhanukov/spring/myworks/dto/TodoDto.java @@ -6,11 +6,12 @@ import lombok.NoArgsConstructor; import lombok.Setter; +import java.time.LocalDateTime; + @Getter @Setter @NoArgsConstructor public class TodoDto { - private Long id; @NotBlank(message = "Title is required") @@ -18,6 +19,6 @@ public class TodoDto { 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 index 128f730..29c7a7f 100644 --- a/src/main/java/com/daniilkhanukov/spring/myworks/entity/Todo.java +++ b/src/main/java/com/daniilkhanukov/spring/myworks/entity/Todo.java @@ -1,38 +1,18 @@ package com.daniilkhanukov.spring.myworks.entity; -import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import java.time.LocalDateTime; -@Entity -@Table(name = "todos") @Getter @Setter @NoArgsConstructor public class Todo { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") private Long id; - - @Column(name = "title", nullable = false) private String title; - - @Column(name = "description", columnDefinition = "TEXT") private String description; - - @Column(name = "completed", nullable = false) - private boolean completed = false; - - @Column(name = "createdAt", nullable = false) - private LocalDateTime createdAt = LocalDateTime.now(); - - public Todo(String title, String description) { - this.title = title; - this.description = 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/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..84ff3c4 --- /dev/null +++ b/src/main/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepository.java @@ -0,0 +1,77 @@ +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 (?, ?, ?)"; + KeyHolder kh = new GeneratedKeyHolder(); + jdbcTemplate.update(conn -> { + PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); + ps.setString(1, t.getTitle()); + ps.setString(2, t.getDescription()); + ps.setBoolean(3, t.isCompleted()); + return ps; + }, kh); + t.setId(kh.getKey().longValue()); + 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() { + return jdbcTemplate.queryForObject("SELECT count(*) FROM todos WHERE completed = true", Long.class); + } +} diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/repository/TodoRepository.java b/src/main/java/com/daniilkhanukov/spring/myworks/repository/TodoRepository.java deleted file mode 100644 index 1a5a70a..0000000 --- a/src/main/java/com/daniilkhanukov/spring/myworks/repository/TodoRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.daniilkhanukov.spring.myworks.repository; - -import com.daniilkhanukov.spring.myworks.entity.Todo; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface TodoRepository extends JpaRepository { - List findByCompleted(boolean completed); -} diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/service/TodoService.java b/src/main/java/com/daniilkhanukov/spring/myworks/service/TodoService.java index 72b2446..ac6925c 100644 --- a/src/main/java/com/daniilkhanukov/spring/myworks/service/TodoService.java +++ b/src/main/java/com/daniilkhanukov/spring/myworks/service/TodoService.java @@ -3,68 +3,104 @@ 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.repository.TodoRepository; +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.Optional; import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class TodoService { - private final TodoRepository repository; + 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); + } - public List getAll(Optional completed) { - List list = completed.map(repository::findByCompleted).orElseGet(repository::findAll); - return list.stream().map(this::toDto).collect(Collectors.toList()); + @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).collect(Collectors.toList()); } public TodoDto getById(Long id) { - Todo dto = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); - return toDto(dto); +// Todo dto = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); +// return toDto(dto); + return repository.findById(id).map(mapper::toDto).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); } + @CacheEvict(value = "todos", allEntries = true) public TodoDto create(TodoDto dto) { - Todo todo = new Todo(); - todo.setTitle(dto.getTitle()); - todo.setDescription(dto.getDescription()); - todo.setCompleted(dto.getCompleted() != null ? dto.getCompleted() : false); - Todo saved = repository.save(todo); - return toDto(saved); + Todo t = mapper.toEntity(dto); + t.setCompleted(dto.getCompleted() != null && dto.getCompleted()); + Todo saved = repository.save(t); + return mapper.toDto(saved); +// Todo todo = new Todo(); +// todo.setTitle(dto.getTitle()); +// todo.setDescription(dto.getDescription()); +// todo.setCompleted(dto.getCompleted() != null ? dto.getCompleted() : false); +// Todo saved = repository.save(todo); +// return toDto(saved); } + @CacheEvict(value = "todos", allEntries = true) public TodoDto update(Long id, TodoDto dto) { - Todo todo = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); - todo.setTitle(dto.getTitle()); - todo.setDescription(dto.getDescription()); - if (dto.getCompleted() != null) { - todo.setCompleted(dto.getCompleted()); - } - Todo saved = repository.save(todo); - return toDto(saved); + Todo existing = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); + existing.setTitle(dto.getTitle()); + existing.setDescription(dto.getDescription()); + if (dto.getCompleted() != null) existing.setCompleted(dto.getCompleted()); + repository.save(existing); + return mapper.toDto(existing); +// Todo todo = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); +// todo.setTitle(dto.getTitle()); +// todo.setDescription(dto.getDescription()); +// if (dto.getCompleted() != null) { +// todo.setCompleted(dto.getCompleted()); +// } +// Todo saved = repository.save(todo); +// return toDto(saved); } + @CacheEvict(value = "todos", allEntries = true) public void delete(Long id) { - if (!repository.existsById(id)) throw new NotFoundException("Todo not found with id: " + id); repository.deleteById(id); +// if (!repository.existsById(id)) throw new NotFoundException("Todo not found with id: " + id); +// repository.deleteById(id); } + @CacheEvict(value = "todos", allEntries = true) public TodoDto toggle(Long id) { - Todo todo = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); - todo.setCompleted(!todo.isCompleted()); - return toDto(repository.save(todo)); + Todo t = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); + t.setCompleted(!t.isCompleted()); + repository.save(t); + return mapper.toDto(t); +// Todo todo = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); +// todo.setCompleted(!todo.isCompleted()); +// return toDto(repository.save(todo)); } - private TodoDto toDto(Todo todo) { - TodoDto dto = new TodoDto(); - dto.setId(todo.getId()); - dto.setTitle(todo.getTitle()); - dto.setDescription(todo.getDescription()); - dto.setCompleted(todo.isCompleted()); - return dto; - } +// private TodoDto toDto(Todo todo) { +// TodoDto dto = new TodoDto(); +// dto.setId(todo.getId()); +// dto.setTitle(todo.getTitle()); +// dto.setDescription(todo.getDescription()); +// dto.setCompleted(todo.isCompleted()); +// return dto; +// } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2a35477..c405a2f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,13 +1,21 @@ spring.application.name=MyWorks -spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= +server.port=8080 -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect -spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=true +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.h2.console.enabled=true -spring.h2.console.path=/h2-console +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..c1cbc06 --- /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") +public 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()); + } + +} From febf8df073adcce5ca9b52d3627bc064d2d1997a Mon Sep 17 00:00:00 2001 From: Daniil Khanukov Date: Wed, 29 Apr 2026 02:55:48 +0300 Subject: [PATCH 3/4] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BE=D0=BD=D0=B0=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 16 -------- .../myworks/controller/TodoController.java | 30 +------------- .../repository/JdbcTodoRepository.java | 6 ++- .../spring/myworks/service/TodoService.java | 41 +++---------------- .../controller/TodoControllerTest.java | 2 +- 5 files changed, 13 insertions(+), 82 deletions(-) diff --git a/pom.xml b/pom.xml index c048717..782c4f7 100644 --- a/pom.xml +++ b/pom.xml @@ -38,7 +38,6 @@ org.projectlombok lombok - 1.18.36 provided @@ -50,19 +49,16 @@ org.testcontainers testcontainers - test org.testcontainers junit-jupiter - test org.testcontainers postgresql - test @@ -133,18 +129,6 @@ org.apache.maven.plugins maven-compiler-plugin - - - - - - - - - - - - diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/controller/TodoController.java b/src/main/java/com/daniilkhanukov/spring/myworks/controller/TodoController.java index 985ed29..53dd88b 100644 --- a/src/main/java/com/daniilkhanukov/spring/myworks/controller/TodoController.java +++ b/src/main/java/com/daniilkhanukov/spring/myworks/controller/TodoController.java @@ -16,9 +16,6 @@ import java.util.Optional; @RestController -//@RequestMapping("/api/todos") -//@RequiredArgsConstructor -//@AllArgsConstructor public class TodoController implements TodoApi { private final TodoService service; @@ -27,29 +24,16 @@ public TodoController(TodoService service) { this.service = service; } -// @GetMapping -// public ResponseEntity> list(@RequestParam Optional completed) { -// return ResponseEntity.ok(service.getAll(completed)); -// } @Override public List list(Boolean completed, int limit, int offset) { return service.getAll(completed, limit, offset); } -// @GetMapping("/{id}") -// public ResponseEntity get(@PathVariable Long id) { -// return ResponseEntity.ok(service.getById(id)); -// } @Override public TodoDto get(Long id) { return service.getById(id); } - // @PostMapping -// public ResponseEntity create(@Valid @RequestBody TodoDto dto) { -// TodoDto created = service.create(dto); -// return ResponseEntity.status(HttpStatus.CREATED).body(created); -// } @Override public TodoDto create(TodoDto dto, HttpServletResponse response) { TodoDto created = service.create(dto); @@ -58,29 +42,17 @@ public TodoDto create(TodoDto dto, HttpServletResponse response) { return created; } -// @PutMapping("/{id}") -// public ResponseEntity update(@PathVariable Long id, @Valid @RequestBody TodoDto dto) { -// return ResponseEntity.ok(service.update(id, dto)); -// } @Override public TodoDto update(Long id, TodoDto dto) { return service.update(id, dto); } -// @DeleteMapping("/{id}") -// public ResponseEntity delete(@PathVariable Long id) { -// service.delete(id); -// return ResponseEntity.noContent().build(); -// } @Override public void delete(Long id) { service.delete(id); } -// @PatchMapping("/{id}/toggle") -// public ResponseEntity toggle(@PathVariable Long id) { -// return ResponseEntity.ok(service.toggle(id)); -// } + @Override public TodoDto toggle(Long id) { return service.toggle(id); diff --git a/src/main/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepository.java b/src/main/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepository.java index 84ff3c4..538725e 100644 --- a/src/main/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepository.java +++ b/src/main/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepository.java @@ -72,6 +72,10 @@ public void deleteById(Long id) { } public long countCompleted() { - return jdbcTemplate.queryForObject("SELECT count(*) FROM todos WHERE completed = true", Long.class); + 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 index ac6925c..75f1fb0 100644 --- a/src/main/java/com/daniilkhanukov/spring/myworks/service/TodoService.java +++ b/src/main/java/com/daniilkhanukov/spring/myworks/service/TodoService.java @@ -21,6 +21,8 @@ @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; @@ -35,13 +37,11 @@ public void initMetrics() { @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).collect(Collectors.toList()); + return list.stream().map(mapper::toDto).toList(); } public TodoDto getById(Long id) { -// Todo dto = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); -// return toDto(dto); - return repository.findById(id).map(mapper::toDto).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); + return repository.findById(id).map(mapper::toDto).orElseThrow(() -> new NotFoundException(TODO_NOT_FOUND + id)); } @CacheEvict(value = "todos", allEntries = true) @@ -50,57 +50,28 @@ public TodoDto create(TodoDto dto) { t.setCompleted(dto.getCompleted() != null && dto.getCompleted()); Todo saved = repository.save(t); return mapper.toDto(saved); -// Todo todo = new Todo(); -// todo.setTitle(dto.getTitle()); -// todo.setDescription(dto.getDescription()); -// todo.setCompleted(dto.getCompleted() != null ? dto.getCompleted() : false); -// Todo saved = repository.save(todo); -// return 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 with id: " + id)); + 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); -// Todo todo = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); -// todo.setTitle(dto.getTitle()); -// todo.setDescription(dto.getDescription()); -// if (dto.getCompleted() != null) { -// todo.setCompleted(dto.getCompleted()); -// } -// Todo saved = repository.save(todo); -// return toDto(saved); } @CacheEvict(value = "todos", allEntries = true) public void delete(Long id) { repository.deleteById(id); -// if (!repository.existsById(id)) throw new NotFoundException("Todo not found with id: " + 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 with id: " + id)); + Todo t = repository.findById(id).orElseThrow(() -> new NotFoundException(TODO_NOT_FOUND + id)); t.setCompleted(!t.isCompleted()); repository.save(t); return mapper.toDto(t); -// Todo todo = repository.findById(id).orElseThrow(() -> new NotFoundException("Todo not found with id: " + id)); -// todo.setCompleted(!todo.isCompleted()); -// return toDto(repository.save(todo)); } - -// private TodoDto toDto(Todo todo) { -// TodoDto dto = new TodoDto(); -// dto.setId(todo.getId()); -// dto.setTitle(todo.getTitle()); -// dto.setDescription(todo.getDescription()); -// dto.setCompleted(todo.isCompleted()); -// return dto; -// } - } diff --git a/src/test/java/com/daniilkhanukov/spring/myworks/controller/TodoControllerTest.java b/src/test/java/com/daniilkhanukov/spring/myworks/controller/TodoControllerTest.java index c1cbc06..320a6b1 100644 --- a/src/test/java/com/daniilkhanukov/spring/myworks/controller/TodoControllerTest.java +++ b/src/test/java/com/daniilkhanukov/spring/myworks/controller/TodoControllerTest.java @@ -23,7 +23,7 @@ @AutoConfigureMockMvc @ActiveProfiles("test") @DisplayName("Integration tests for TodoController") -public class TodoControllerTest { +class TodoControllerTest { @Container static PostgreSQLContainer postgres = From 19f4faf2338401f37824877ac644afb9d1f5f9c7 Mon Sep 17 00:00:00 2001 From: Daniil Khanukov Date: Thu, 30 Apr 2026 05:06:36 +0300 Subject: [PATCH 4/4] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BE=D0=BD=D0=B0=D1=80=D0=B0?= =?UTF-8?q?=20+=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20>=208?= =?UTF-8?q?0%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 36 ++- .../repository/JdbcTodoRepository.java | 25 ++- .../exception/NotFoundExceptionTest.java | 16 ++ .../repository/JdbcTodoRepositoryTest.java | 176 +++++++++++++++ .../myworks/service/TodoServiceTest.java | 209 ++++++++++++++++++ .../resources/application-test.properties | 2 + 6 files changed, 449 insertions(+), 15 deletions(-) create mode 100644 src/test/java/com/daniilkhanukov/spring/myworks/exception/NotFoundExceptionTest.java create mode 100644 src/test/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepositoryTest.java create mode 100644 src/test/java/com/daniilkhanukov/spring/myworks/service/TodoServiceTest.java create mode 100644 src/test/resources/application-test.properties diff --git a/pom.xml b/pom.xml index 782c4f7..eaee356 100644 --- a/pom.xml +++ b/pom.xml @@ -94,6 +94,11 @@ flyway-core + + org.flywaydb + flyway-database-postgresql + + org.mapstruct mapstruct @@ -109,7 +114,7 @@ org.springdoc springdoc-openapi-starter-webmvc-ui - 3.0.1 + 2.8.7 compile @@ -130,14 +135,35 @@ org.apache.maven.plugins maven-compiler-plugin - - + + org.projectlombok lombok - - + 1.18.36 + + + + 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/repository/JdbcTodoRepository.java b/src/main/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepository.java index 538725e..dd5eb15 100644 --- a/src/main/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepository.java +++ b/src/main/java/com/daniilkhanukov/spring/myworks/repository/JdbcTodoRepository.java @@ -49,16 +49,21 @@ public List findByCompleted(boolean completed, int limit, int offset) { public Todo save(Todo t) { if (t.getId() == null) { - String sql = "INSERT INTO todos (title, description, completed) VALUES (?, ?, ?)"; - KeyHolder kh = new GeneratedKeyHolder(); - jdbcTemplate.update(conn -> { - PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); - ps.setString(1, t.getTitle()); - ps.setString(2, t.getDescription()); - ps.setBoolean(3, t.isCompleted()); - return ps; - }, kh); - t.setId(kh.getKey().longValue()); + 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 = ?"; 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