diff --git a/.gitignore b/.gitignore index d3bedd1..e4a6f6e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,36 @@ -# Compiled class file -*.class +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ -# Log file -*.log -*.DS_Store +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache -# BlueJ files -*.ctxt +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +*.DS_Store -# Mobile Tools for Java (J2ME) -.mtj.tmp/ -# Package Files # -*.jar -*.war -*.nar -*.ear -*.zip -*.tar.gz -*.rar +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/image/** +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* +### VS Code ### +.vscode/ diff --git a/.idea/amplicode-jpa.xml b/.idea/amplicode-jpa.xml index 98351bf..f6a56e2 100644 --- a/.idea/amplicode-jpa.xml +++ b/.idea/amplicode-jpa.xml @@ -1,6 +1,8 @@ - + + \ No newline at end of file diff --git a/.idea/amplicode-settings.xml b/.idea/amplicode-settings.xml new file mode 100644 index 0000000..b965efa --- /dev/null +++ b/.idea/amplicode-settings.xml @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..795ca79 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/dataSources.local.xml b/.idea/dataSources.local.xml new file mode 100644 index 0000000..10a6f99 --- /dev/null +++ b/.idea/dataSources.local.xml @@ -0,0 +1,20 @@ + + + + + + " + + + master_key + sa + + + + + + + + + + \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..ab41c1c --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,17 @@ + + + + + h2.unified + true + org.h2.Driver + jdbc:h2:mem:testdb + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/dataSources/f04243a9-2346-4975-b5d1-b9ee4bd49928.xml b/.idea/dataSources/f04243a9-2346-4975-b5d1-b9ee4bd49928.xml new file mode 100644 index 0000000..7a05fb5 --- /dev/null +++ b/.idea/dataSources/f04243a9-2346-4975-b5d1-b9ee4bd49928.xml @@ -0,0 +1,178 @@ + + + + + 2.2.220 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 2025-04-27.14:25:53 + + + 1 + + + \ No newline at end of file diff --git a/.idea/dataSources/f04243a9-2346-4975-b5d1-b9ee4bd49928/storage_v2/_src_/database/TESTDB.kGhKkw.meta b/.idea/dataSources/f04243a9-2346-4975-b5d1-b9ee4bd49928/storage_v2/_src_/database/TESTDB.kGhKkw.meta new file mode 100644 index 0000000..5d0718d --- /dev/null +++ b/.idea/dataSources/f04243a9-2346-4975-b5d1-b9ee4bd49928/storage_v2/_src_/database/TESTDB.kGhKkw.meta @@ -0,0 +1 @@ +#n:TESTDB \ No newline at end of file diff --git a/.idea/dataSources/f04243a9-2346-4975-b5d1-b9ee4bd49928/storage_v2/_src_/database/TESTDB.kGhKkw/schema/PUBLIC.aaZQjQ.meta b/.idea/dataSources/f04243a9-2346-4975-b5d1-b9ee4bd49928/storage_v2/_src_/database/TESTDB.kGhKkw/schema/PUBLIC.aaZQjQ.meta new file mode 100644 index 0000000..3fdc81a --- /dev/null +++ b/.idea/dataSources/f04243a9-2346-4975-b5d1-b9ee4bd49928/storage_v2/_src_/database/TESTDB.kGhKkw/schema/PUBLIC.aaZQjQ.meta @@ -0,0 +1,2 @@ +#n:PUBLIC +! [0, 0, null, null, -2147483648, -2147483648] diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..1926ee2 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..712ab9d --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 6a61e88..864598a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,5 +1,22 @@ + + + + + + + diff --git a/amplicode.xml b/amplicode.xml new file mode 100644 index 0000000..50bc959 --- /dev/null +++ b/amplicode.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/task.csv b/resources/task.csv deleted file mode 100644 index f86272e..0000000 --- a/resources/task.csv +++ /dev/null @@ -1,4 +0,0 @@ -id,type,name,status,description,epic -1,TASK,Дом,NEW,Убраться в кухни и ванной,2024-06-19T20:18:39.706859,2024-06-19T20:58:39.706859 -2,TASK,Работа,IN_PROGRESS,Сделать куча рутины и пойти домой:),2024-06-20T20:18:39.719817,2024-06-20T21:08:39.719817 - diff --git a/service/Dockerfile b/service/Dockerfile new file mode 100644 index 0000000..9fd6682 --- /dev/null +++ b/service/Dockerfile @@ -0,0 +1,4 @@ +FROM ubuntu:latest +LABEL authors="admin" + +ENTRYPOINT ["top", "-b"] \ No newline at end of file diff --git a/service/pom.xml b/service/pom.xml new file mode 100644 index 0000000..2ca9bc6 --- /dev/null +++ b/service/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + service.task.manager + service + 1.0-SNAPSHOT + + + 21 + 21 + UTF-8 + 2.6.0 + 1.6.0 + 1.18.34 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.mockito + mockito-core + 5.12.0 + test + + + org.mockito + mockito-junit-jupiter + 5.12.0 + test + + + + + + + + org.projectlombok + lombok + 1.18.34 + provided + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-validation + + + com.h2database + h2 + runtime + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 21 + 21 + + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.6.0 + + + generate-javadoc + generate-resources + + javadoc + + + + + 21 + none + ${project.basedir}/docs/javadoc + + + + + + \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/TaskManagerApplication.java b/service/src/main/java/service/task/manager/TaskManagerApplication.java new file mode 100644 index 0000000..acb9e68 --- /dev/null +++ b/service/src/main/java/service/task/manager/TaskManagerApplication.java @@ -0,0 +1,11 @@ +package service.task.manager; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TaskManagerApplication { + public static void main(String[] args) { + SpringApplication.run(TaskManagerApplication.class, args); + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/config/RedisConfig.java b/service/src/main/java/service/task/manager/config/RedisConfig.java new file mode 100644 index 0000000..74d82ec --- /dev/null +++ b/service/src/main/java/service/task/manager/config/RedisConfig.java @@ -0,0 +1,20 @@ +package service.task.manager.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import service.task.manager.model.HistoryEntry; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setDefaultSerializer(new Jackson2JsonRedisSerializer<>(HistoryEntry.class)); + return template; + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/controller/EpicController.java b/service/src/main/java/service/task/manager/controller/EpicController.java new file mode 100644 index 0000000..59aac2b --- /dev/null +++ b/service/src/main/java/service/task/manager/controller/EpicController.java @@ -0,0 +1,92 @@ +package service.task.manager.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import service.task.manager.dto.epic.EpicRequestCreatedDto; +import service.task.manager.dto.epic.EpicRequestUpdatedDto; +import service.task.manager.dto.epic.EpicResponseDto; +import service.task.manager.service.EpicService; + +import java.util.List; + +@Slf4j +@CrossOrigin +@RestController +@RequiredArgsConstructor +@RequestMapping("/epic") +@Tag(name = "Epic API", description = "API for managing epics") +public class EpicController { + private final EpicService service; + + @PostMapping + @Operation(summary = "Create a new epic", description = "Creates a new epic with the provided data.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Epic created successfully"), + @ApiResponse(responseCode = "409", description = "Epic with the same name already exists") + }) + public ResponseEntity create(@RequestBody @Valid EpicRequestCreatedDto dto) { + log.info("Creating epic with name: {}", dto.name()); + service.create(dto); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping + @Operation(summary = "Update an existing epic", description = "Updates an epic with the provided data.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Epic updated successfully"), + @ApiResponse(responseCode = "404", description = "Epic not found") + }) + public ResponseEntity update(@RequestBody @Valid EpicRequestUpdatedDto dto) { + log.info("Updating epic with ID: {}", dto.id()); + EpicResponseDto updatedEpic = service.update(dto); + return ResponseEntity.ok(updatedEpic); + } + + @GetMapping("/{id}") + @Operation(summary = "Get an epic by ID", description = "Retrieves an epic by its ID, including its subtasks.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Epic retrieved successfully"), + @ApiResponse(responseCode = "404", description = "Epic not found") + }) + public ResponseEntity findById( + @Parameter(description = "ID of the epic to retrieve") @PathVariable @Positive(message = "id must be positive") + @NotNull(message = "null id") Long id) { + log.info("Fetching epic with ID: {}", id); + EpicResponseDto epic = service.findById(id); + return ResponseEntity.ok(epic); + } + + @GetMapping + @Operation(summary = "Get all epics", description = "Retrieves a list of all epics.") + @ApiResponse(responseCode = "200", description = "List of epics retrieved successfully") + public ResponseEntity> findAll() { + log.info("Fetching all epics"); + List epics = service.findAll(); + return ResponseEntity.ok(epics); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete an epic by ID", description = "Deletes an epic by its ID.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Epic deleted successfully"), + @ApiResponse(responseCode = "404", description = "Epic not found") + }) + public ResponseEntity delete( + @Parameter(description = "ID of the epic to delete") @PathVariable @Positive(message = "id must be positive") + @NotNull(message = "null id") Long id) { + log.info("Deleting epic with ID: {}", id); + service.delete(id); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/controller/HistoryController.java b/service/src/main/java/service/task/manager/controller/HistoryController.java new file mode 100644 index 0000000..d877200 --- /dev/null +++ b/service/src/main/java/service/task/manager/controller/HistoryController.java @@ -0,0 +1,60 @@ +package service.task.manager.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import service.task.manager.model.HistoryEntry; +import service.task.manager.service.HistoryService; + +import java.util.List; + +/** + * Controller for managing and retrieving the history of task accesses. + */ +@Slf4j +@CrossOrigin +@RestController +@RequiredArgsConstructor +@RequestMapping("/history") +@Tag(name = "History API", description = "API for retrieving the history of task accesses") +public class HistoryController { + + private final HistoryService service; + + /** + * Retrieves the history of task accesses. + * The history contains the last 10 records of calls to the findBy(long id) method for tasks, epics, and subtasks. + * + * @return a list of history entries + */ + @GetMapping + @Operation(summary = "Get task access history", description = "Retrieves the history of the last 10 task accesses (Task, Epic, Subtask) made via the findBy(long id) method.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "History retrieved successfully", + content = @Content(mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = HistoryEntry.class)))), + @ApiResponse(responseCode = "500", description = "Internal server error, possibly due to Redis connectivity issues", + content = @Content) + }) + public List getHistory() { + try { + log.info("Received request to retrieve task access history"); + List history = service.getHistory(); + log.debug("Successfully retrieved history with {} entries", history.size()); + return history; + } catch (Exception e) { + log.error("Failed to retrieve task access history: {}", e.getMessage(), e); + throw new RuntimeException("Failed to retrieve history", e); + } + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/controller/SubtaskController.java b/service/src/main/java/service/task/manager/controller/SubtaskController.java new file mode 100644 index 0000000..933a4d3 --- /dev/null +++ b/service/src/main/java/service/task/manager/controller/SubtaskController.java @@ -0,0 +1,93 @@ +package service.task.manager.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import service.task.manager.dto.subtask.SubtaskRequestCreatedDto; +import service.task.manager.dto.subtask.SubtaskRequestUpdatedDto; +import service.task.manager.dto.subtask.SubtaskResponseDto; +import service.task.manager.service.SubtaskService; + +import java.util.List; + +@Slf4j +@CrossOrigin +@RestController +@RequiredArgsConstructor +@RequestMapping("/subtask") +@Tag(name = "Subtask API", description = "API for managing subtasks") +public class SubtaskController { + private final SubtaskService service; + + @PostMapping + @Operation(summary = "Create a new subtask", description = "Creates a new subtask with the provided data, associated with an epic.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Subtask created successfully"), + @ApiResponse(responseCode = "404", description = "Associated epic not found"), + @ApiResponse(responseCode = "409", description = "Subtask with the same name already exists") + }) + public ResponseEntity create(@RequestBody @Valid SubtaskRequestCreatedDto dto) { + log.info("Creating subtask with name: {}", dto.name()); + service.create(dto); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping + @Operation(summary = "Update an existing subtask", description = "Updates an existing subtask with the provided data.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Subtask updated successfully"), + @ApiResponse(responseCode = "404", description = "Subtask not found") + }) + public ResponseEntity update(@RequestBody @Valid SubtaskRequestUpdatedDto dto) { + log.info("Updating subtask with ID: {}", dto.id()); + SubtaskResponseDto updatedSubtask = service.update(dto); + return ResponseEntity.ok(updatedSubtask); + } + + @GetMapping("/{id}") + @Operation(summary = "Get a subtask by ID", description = "Retrieves a subtask by its ID.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Subtask retrieved successfully"), + @ApiResponse(responseCode = "404", description = "Subtask not found") + }) + public ResponseEntity findById( + @Parameter(description = "ID of the subtask to retrieve") @PathVariable @Positive(message = "id must be positive") + @NotNull(message = "null id") Long id) { + log.info("Fetching subtask with ID: {}", id); + SubtaskResponseDto subtask = service.findById(id); + return ResponseEntity.ok(subtask); + } + + @GetMapping + @Operation(summary = "Get all subtasks", description = "Retrieves a list of all subtasks.") + @ApiResponse(responseCode = "200", description = "List of subtasks retrieved successfully") + public ResponseEntity> findAll() { + log.info("Fetching all subtasks"); + List subtasks = service.findAll(); + return ResponseEntity.ok(subtasks); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete a subtask by ID", description = "Deletes a subtask by its ID.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Subtask deleted successfully"), + @ApiResponse(responseCode = "404", description = "Subtask not found") + }) + public ResponseEntity delete( + @Parameter(description = "ID of the subtask to delete") @PathVariable @Positive(message = "id must be positive") + @NotNull(message = "null id") Long id) { + log.info("Deleting subtask with ID: {}", id); + service.delete(id); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/controller/TaskController.java b/service/src/main/java/service/task/manager/controller/TaskController.java new file mode 100644 index 0000000..8ec5844 --- /dev/null +++ b/service/src/main/java/service/task/manager/controller/TaskController.java @@ -0,0 +1,92 @@ +package service.task.manager.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import service.task.manager.dto.task.TaskRequestCreatedDto; +import service.task.manager.dto.task.TaskRequestUpdatedDto; +import service.task.manager.dto.task.TaskResponseDto; +import service.task.manager.service.TaskService; + +import java.util.List; + +@Slf4j +@CrossOrigin +@RestController +@RequiredArgsConstructor +@RequestMapping("/task") +@Tag(name = "Task API", description = "API for managing tasks") +public class TaskController { + private final TaskService service; + + @PostMapping + @Operation(summary = "Create a new task", description = "Creates a new task with the provided data.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Task created successfully"), + @ApiResponse(responseCode = "409", description = "Task with the same name already exists") + }) + public ResponseEntity create(@RequestBody @Valid TaskRequestCreatedDto dto) { + log.info("Creating task with name: {}", dto.name()); + service.create(dto); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping + @Operation(summary = "Update an existing task", description = "Updates an existing task with the provided data.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Task updated successfully"), + @ApiResponse(responseCode = "404", description = "Task not found") + }) + public ResponseEntity update(@RequestBody @Valid TaskRequestUpdatedDto dto) { + log.info("Updating task with ID: {}", dto.id()); + TaskResponseDto updatedTask = service.update(dto); + return ResponseEntity.ok(updatedTask); + } + + @GetMapping("/{id}") + @Operation(summary = "Get a task by ID", description = "Retrieves a task by its ID.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Task retrieved successfully"), + @ApiResponse(responseCode = "404", description = "Task not found") + }) + public ResponseEntity get( + @Parameter(description = "ID of the task to retrieve") @PathVariable @Positive(message = "id must be positive") + @NotNull(message = "null id") Long id) { + log.info("Fetching task with ID: {}", id); + TaskResponseDto task = service.findById(id); + return ResponseEntity.ok(task); + } + + @GetMapping + @Operation(summary = "Get all tasks", description = "Retrieves a list of all tasks.") + @ApiResponse(responseCode = "200", description = "List of tasks retrieved successfully") + public ResponseEntity> getAll() { + log.info("Fetching all tasks"); + List tasks = service.findAll(); + return ResponseEntity.ok(tasks); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete a task by ID", description = "Deletes a task by its ID.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Task deleted successfully"), + @ApiResponse(responseCode = "404", description = "Task not found") + }) + public ResponseEntity delete( + @Parameter(description = "ID of the task to delete") @PathVariable @Positive(message = "id must be positive") + @NotNull(message = "null id") Long id) { + log.info("Deleting task with ID: {}", id); + service.delete(id); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/dto/epic/EpicRequestCreatedDto.java b/service/src/main/java/service/task/manager/dto/epic/EpicRequestCreatedDto.java new file mode 100644 index 0000000..da54e47 --- /dev/null +++ b/service/src/main/java/service/task/manager/dto/epic/EpicRequestCreatedDto.java @@ -0,0 +1,33 @@ +package service.task.manager.dto.epic; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import java.time.Duration; +import java.time.LocalDateTime; + +/** + * DTO for creating a new epic. + */ +@Schema(description = "DTO for creating a new epic") +@Builder +public record EpicRequestCreatedDto( + @Schema(description = "Name of the epic", example = "Project Planning", required = true) + @NotBlank(message = "blank name") + String name, + + @Schema(description = "Description of the epic", example = "Planning phase of the project", required = true) + @NotBlank(message = "blank description") + String description, + + @Schema(description = "Start time of the epic", example = "2025-04-27T10:00:00", required = true) + @NotNull(message = "null start time") + LocalDateTime startTime, + + @Schema(description = "Duration of the epic", example = "PT24H", required = true) + @NotNull(message = "null duration") + Duration duration +) { +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/dto/epic/EpicRequestUpdatedDto.java b/service/src/main/java/service/task/manager/dto/epic/EpicRequestUpdatedDto.java new file mode 100644 index 0000000..0fc68f4 --- /dev/null +++ b/service/src/main/java/service/task/manager/dto/epic/EpicRequestUpdatedDto.java @@ -0,0 +1,37 @@ +package service.task.manager.dto.epic; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import service.task.manager.model.enums.Status; + +import java.time.Duration; + +/** + * DTO for updating an existing epic. + */ +@Schema(description = "DTO for updating an existing epic") +public record EpicRequestUpdatedDto( + @Schema(description = "ID of the epic to update", example = "1", required = true) + @NotNull(message = "null id") + @Positive(message = "not positive id") + Long id, + + @Schema(description = "Updated name of the epic", example = "Updated Project Planning", required = true) + @NotBlank(message = "blank name") + String name, + + @Schema(description = "Updated description of the epic", example = "Updated planning phase", required = true) + @NotBlank(message = "blank description") + String description, + + @Schema(description = "Updated status of the epic", example = "IN_PROGRESS", required = true) + @NotNull(message = "null status") + Status status, + + @Schema(description = "Updated duration of the epic", example = "PT48H", required = true) + @NotNull(message = "null duration") + Duration duration +) { +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/dto/epic/EpicResponseDto.java b/service/src/main/java/service/task/manager/dto/epic/EpicResponseDto.java new file mode 100644 index 0000000..8938b17 --- /dev/null +++ b/service/src/main/java/service/task/manager/dto/epic/EpicResponseDto.java @@ -0,0 +1,61 @@ +package service.task.manager.dto.epic; + +import io.swagger.v3.oas.annotations.media.Schema; +import service.task.manager.model.enums.Status; +import service.task.manager.model.enums.TaskType; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +/** + * DTO for retrieving an epic with its details and subtasks. + */ +@Schema(description = "DTO for retrieving an epic with its details and subtasks") +public record EpicResponseDto( + @Schema(description = "ID of the epic", example = "1") + Long id, + + @Schema(description = "List of subtasks associated with the epic") + List subtasks, + + @Schema(description = "Name of the epic", example = "Project Planning") + String name, + + @Schema(description = "Description of the epic", example = "Planning phase of the project") + String description, + + @Schema(description = "Status of the epic", example = "NEW") + Status status, + + @Schema(description = "Start time of the epic", example = "2025-04-27T10:00:00") + LocalDateTime startTime, + + @Schema(description = "Duration of the epic", example = "PT24H") + Duration duration, + + @Schema(description = "End time of the epic", example = "2025-04-28T10:00:00") + LocalDateTime endTime, + + @Schema(description = "Type of the task (always EPIC)", example = "EPIC") + TaskType type +) { + /** + * DTO for retrieving a subtask within an epic. + */ + @Schema(description = "DTO for retrieving a subtask within an epic") + public record SubtaskDto( + @Schema(description = "ID of the subtask", example = "1") + Long id, + + @Schema(description = "Name of the subtask", example = "Task 1") + String name, + + @Schema(description = "Description of the subtask", example = "First task in the epic") + String description, + + @Schema(description = "Status of the subtask", example = "NEW") + Status status + ) { + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/dto/subtask/SubtaskRequestCreatedDto.java b/service/src/main/java/service/task/manager/dto/subtask/SubtaskRequestCreatedDto.java new file mode 100644 index 0000000..f4bda8e --- /dev/null +++ b/service/src/main/java/service/task/manager/dto/subtask/SubtaskRequestCreatedDto.java @@ -0,0 +1,37 @@ +package service.task.manager.dto.subtask; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import java.time.Duration; +import java.time.LocalDateTime; + +/** + * DTO for creating a new subtask. + */ +@Schema(description = "DTO for creating a new subtask") +@Builder +public record SubtaskRequestCreatedDto( + @Schema(description = "ID of the epic to which the subtask belongs", example = "1", required = true) + @NotNull(message = "null epic ID") + Long epicId, + + @Schema(description = "Name of the subtask", example = "Task 1", required = true) + @NotBlank(message = "blank name") + String name, + + @Schema(description = "Description of the subtask", example = "First task in the epic", required = true) + @NotBlank(message = "blank description") + String description, + + @Schema(description = "Start time of the subtask", example = "2025-04-27T10:00:00", required = true) + @NotNull(message = "null start time") + LocalDateTime startTime, + + @Schema(description = "Duration of the subtask", example = "PT24H", required = true) + @NotNull(message = "null duration") + Duration duration +) { +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/dto/subtask/SubtaskRequestUpdatedDto.java b/service/src/main/java/service/task/manager/dto/subtask/SubtaskRequestUpdatedDto.java new file mode 100644 index 0000000..f258da5 --- /dev/null +++ b/service/src/main/java/service/task/manager/dto/subtask/SubtaskRequestUpdatedDto.java @@ -0,0 +1,37 @@ +package service.task.manager.dto.subtask; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import service.task.manager.model.enums.Status; + +import java.time.Duration; + +/** + * DTO for updating an existing subtask. + */ +@Schema(description = "DTO for updating an existing subtask") +public record SubtaskRequestUpdatedDto( + @Schema(description = "ID of the subtask to update", example = "1", required = true) + @NotNull(message = "null id") + @Positive(message = "not positive id") + Long id, + + @Schema(description = "Updated name of the subtask", example = "Updated Task 1", required = true) + @NotBlank(message = "blank name") + String name, + + @Schema(description = "Updated description of the subtask", example = "Updated first task", required = true) + @NotBlank(message = "blank description") + String description, + + @Schema(description = "Updated status of the subtask", example = "IN_PROGRESS", required = true) + @NotNull(message = "null status") + Status status, + + @Schema(description = "Updated duration of the subtask", example = "PT48H", required = true) + @NotNull(message = "null duration") + Duration duration +) { +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/dto/subtask/SubtaskResponseDto.java b/service/src/main/java/service/task/manager/dto/subtask/SubtaskResponseDto.java new file mode 100644 index 0000000..dbcf06c --- /dev/null +++ b/service/src/main/java/service/task/manager/dto/subtask/SubtaskResponseDto.java @@ -0,0 +1,42 @@ +package service.task.manager.dto.subtask; + +import io.swagger.v3.oas.annotations.media.Schema; +import service.task.manager.model.enums.Status; +import service.task.manager.model.enums.TaskType; + +import java.time.Duration; +import java.time.LocalDateTime; + +/** + * DTO for retrieving a subtask with its details. + */ +@Schema(description = "DTO for retrieving a subtask with its details") +public record SubtaskResponseDto( + @Schema(description = "ID of the subtask", example = "1") + Long id, + + @Schema(description = "ID of the epic to which the subtask belongs", example = "1") + Long epicId, + + @Schema(description = "Name of the subtask", example = "Task 1") + String name, + + @Schema(description = "Description of the subtask", example = "First task in the epic") + String description, + + @Schema(description = "Status of the subtask", example = "NEW") + Status status, + + @Schema(description = "Start time of the subtask", example = "2025-04-27T10:00:00") + LocalDateTime startTime, + + @Schema(description = "End time of the subtask", example = "2025-04-28T10:00:00") + LocalDateTime endTime, + + @Schema(description = "Duration of the subtask", example = "PT24H") + Duration duration, + + @Schema(description = "Type of the task (always SUBTASK)", example = "SUBTASK") + TaskType type +) { +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/dto/task/TaskRequestCreatedDto.java b/service/src/main/java/service/task/manager/dto/task/TaskRequestCreatedDto.java new file mode 100644 index 0000000..325b668 --- /dev/null +++ b/service/src/main/java/service/task/manager/dto/task/TaskRequestCreatedDto.java @@ -0,0 +1,31 @@ +package service.task.manager.dto.task; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.Duration; +import java.time.LocalDateTime; + +/** + * DTO for creating a new task. + */ +@Schema(description = "DTO for creating a new task") +public record TaskRequestCreatedDto( + @Schema(description = "Name of the task", example = "Standalone Task", required = true) + @NotBlank(message = "blank name") + String name, + + @Schema(description = "Description of the task", example = "A standalone task", required = true) + @NotBlank(message = "blank description") + String description, + + @Schema(description = "Start time of the task", example = "2025-04-27T10:00:00", required = true) + @NotNull(message = "null start time") + LocalDateTime startTime, + + @Schema(description = "Duration of the task", example = "PT24H", required = true) + @NotNull(message = "null duration") + Duration duration +) { +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/dto/task/TaskRequestUpdatedDto.java b/service/src/main/java/service/task/manager/dto/task/TaskRequestUpdatedDto.java new file mode 100644 index 0000000..8677443 --- /dev/null +++ b/service/src/main/java/service/task/manager/dto/task/TaskRequestUpdatedDto.java @@ -0,0 +1,37 @@ +package service.task.manager.dto.task; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import service.task.manager.model.enums.Status; + +import java.time.Duration; + +/** + * DTO for updating an existing task. + */ +@Schema(description = "DTO for updating an existing task") +public record TaskRequestUpdatedDto( + @Schema(description = "ID of the task to update", example = "1", required = true) + @NotNull(message = "null id") + @Positive(message = "not positive id") + Long id, + + @Schema(description = "Updated name of the task", example = "Updated Task", required = true) + @NotBlank(message = "blank name") + String name, + + @Schema(description = "Updated description of the task", example = "Updated standalone task", required = true) + @NotBlank(message = "blank description") + String description, + + @Schema(description = "Updated status of the task", example = "IN_PROGRESS", required = true) + @NotNull(message = "null status") + Status status, + + @Schema(description = "Updated duration of the task", example = "PT48H", required = true) + @NotNull(message = "null duration") + Duration duration +) { +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/dto/task/TaskResponseDto.java b/service/src/main/java/service/task/manager/dto/task/TaskResponseDto.java new file mode 100644 index 0000000..0e60736 --- /dev/null +++ b/service/src/main/java/service/task/manager/dto/task/TaskResponseDto.java @@ -0,0 +1,39 @@ +package service.task.manager.dto.task; + +import io.swagger.v3.oas.annotations.media.Schema; +import service.task.manager.model.enums.Status; +import service.task.manager.model.enums.TaskType; + +import java.time.Duration; +import java.time.LocalDateTime; + +/** + * DTO for retrieving a task with its details. + */ +@Schema(description = "DTO for retrieving a task with its details") +public record TaskResponseDto( + @Schema(description = "ID of the task", example = "1") + Long id, + + @Schema(description = "Name of the task", example = "Standalone Task") + String name, + + @Schema(description = "Description of the task", example = "A standalone task") + String description, + + @Schema(description = "Status of the task", example = "NEW") + Status status, + + @Schema(description = "Start time of the task", example = "2025-04-27T10:00:00") + LocalDateTime startTime, + + @Schema(description = "End time of the task", example = "2025-04-28T10:00:00") + LocalDateTime endTime, + + @Schema(description = "Duration of the task", example = "PT24H") + Duration duration, + + @Schema(description = "Type of the task (always TASK)", example = "TASK") + TaskType type +) { +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/error/ConflictException.java b/service/src/main/java/service/task/manager/error/ConflictException.java new file mode 100644 index 0000000..cd69165 --- /dev/null +++ b/service/src/main/java/service/task/manager/error/ConflictException.java @@ -0,0 +1,7 @@ +package service.task.manager.error; + +public class ConflictException extends RuntimeException { + public ConflictException(String message) { + super(message); + } +} diff --git a/service/src/main/java/service/task/manager/error/ErrorHandler.java b/service/src/main/java/service/task/manager/error/ErrorHandler.java new file mode 100644 index 0000000..7bb0c18 --- /dev/null +++ b/service/src/main/java/service/task/manager/error/ErrorHandler.java @@ -0,0 +1,88 @@ +package service.task.manager.error; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class ErrorHandler { + + @ExceptionHandler(NotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ErrorResponse handleNotFoundException(NotFoundException ex) { + return new ErrorResponse(ex.getMessage()); + } + + @ExceptionHandler(InvalidTaskDataException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleInvalidTaskDataException(InvalidTaskDataException ex) { + return new ErrorResponse(ex.getMessage()); + } + + @ExceptionHandler(TaskConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleTaskConstraintViolationException(TaskConstraintViolationException ex) { + return new ErrorResponse(ex.getMessage()); + } + + @ExceptionHandler(ConflictException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public ErrorResponse handleConflictException(ConflictException ex) { + return new ErrorResponse(ex.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + for (FieldError error : ex.getBindingResult().getFieldErrors()) { + errors.put(error.getField(), error.getDefaultMessage()); + } + return errors; + } + + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleConstraintViolationException(ConstraintViolationException ex) { + Map errors = new HashMap<>(); + for (ConstraintViolation violation : ex.getConstraintViolations()) { + String fieldName = violation.getPropertyPath().toString(); + fieldName = fieldName.substring(fieldName.lastIndexOf('.') + 1); + errors.put(fieldName, violation.getMessage()); + } + return errors; + } + + @ExceptionHandler(HandlerMethodValidationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleHandlerMethodValidationException(HandlerMethodValidationException ex) { + Map errors = new HashMap<>(); + for (MessageSourceResolvable resolvable : ex.getAllErrors()) { + if (resolvable instanceof FieldError fieldError) { + errors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } else if (resolvable instanceof ObjectError objectError) { + // Для параметров метода (например, @PathVariable) + String paramName = objectError.getObjectName(); + errors.put(paramName, objectError.getDefaultMessage()); + } else { + // Для случаев, когда resolvable не является ObjectError + errors.put("error", resolvable.getDefaultMessage()); + } + } + return errors; + } + + record ErrorResponse(String message) { + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/error/InvalidTaskDataException.java b/service/src/main/java/service/task/manager/error/InvalidTaskDataException.java new file mode 100644 index 0000000..a74186e --- /dev/null +++ b/service/src/main/java/service/task/manager/error/InvalidTaskDataException.java @@ -0,0 +1,7 @@ +package service.task.manager.error; + +public class InvalidTaskDataException extends RuntimeException { + public InvalidTaskDataException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/error/NotFoundException.java b/service/src/main/java/service/task/manager/error/NotFoundException.java new file mode 100644 index 0000000..f4f56c6 --- /dev/null +++ b/service/src/main/java/service/task/manager/error/NotFoundException.java @@ -0,0 +1,7 @@ +package service.task.manager.error; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} diff --git a/service/src/main/java/service/task/manager/error/TaskConstraintViolationException.java b/service/src/main/java/service/task/manager/error/TaskConstraintViolationException.java new file mode 100644 index 0000000..3a5b163 --- /dev/null +++ b/service/src/main/java/service/task/manager/error/TaskConstraintViolationException.java @@ -0,0 +1,7 @@ +package service.task.manager.error; + +public class TaskConstraintViolationException extends RuntimeException { + public TaskConstraintViolationException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/mapper/EpicMapper.java b/service/src/main/java/service/task/manager/mapper/EpicMapper.java new file mode 100644 index 0000000..742ace8 --- /dev/null +++ b/service/src/main/java/service/task/manager/mapper/EpicMapper.java @@ -0,0 +1,42 @@ +package service.task.manager.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.mapstruct.MappingTarget; +import service.task.manager.dto.epic.EpicRequestCreatedDto; +import service.task.manager.dto.epic.EpicRequestUpdatedDto; +import service.task.manager.dto.epic.EpicResponseDto; +import service.task.manager.dto.subtask.SubtaskRequestUpdatedDto; +import service.task.manager.model.Epic; +import service.task.manager.model.Subtask; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public interface EpicMapper { + + // Маппинг из DTO в сущность Epic + Epic toEntity(EpicRequestCreatedDto epicRequestCreatedDto); + + // Маппинг из сущности Epic в DTO ответа + EpicResponseDto toResponseDto(Epic epic); + + // Маппинг для подзадач (Subtask -> SubtaskDto) + EpicResponseDto.SubtaskDto toSubtaskDto(Subtask subtask); + + Subtask toEntity(SubtaskRequestUpdatedDto subtaskRequestUpdatedDto); + + SubtaskRequestUpdatedDto toSubtaskRequestUpdatedDto(Subtask subtask); + + Epic toEntity(EpicRequestUpdatedDto epicRequestUpdatedDto); + + Epic toEntity(EpicResponseDto dto); + + EpicRequestUpdatedDto toEpicDto(Epic epic); + + @Mapping(target = "id", ignore = true) // Не обновляем ID + @Mapping(target = "startTime", ignore = true) // Оставляем startTime из базы + @Mapping(target = "subtasks", ignore = true) + @Mapping(target = "endTime", ignore = true) + // endTime рассчитывается в @PreUpdate + void updateTaskFromDto(EpicRequestUpdatedDto dto, @MappingTarget Epic epic); +} diff --git a/service/src/main/java/service/task/manager/mapper/SubtaskMapper.java b/service/src/main/java/service/task/manager/mapper/SubtaskMapper.java new file mode 100644 index 0000000..062a2fe --- /dev/null +++ b/service/src/main/java/service/task/manager/mapper/SubtaskMapper.java @@ -0,0 +1,31 @@ +package service.task.manager.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.mapstruct.MappingTarget; +import service.task.manager.dto.subtask.SubtaskRequestCreatedDto; +import service.task.manager.dto.subtask.SubtaskRequestUpdatedDto; +import service.task.manager.dto.subtask.SubtaskResponseDto; +import service.task.manager.model.Subtask; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public interface SubtaskMapper { + + // Маппинг из DTO в сущность Subtask + Subtask toEntity(SubtaskRequestCreatedDto dto); + + // Маппинг из сущности Subtask в DTO ответа + SubtaskResponseDto toResponseDto(Subtask subtask); + + Subtask toEntity(SubtaskRequestUpdatedDto dto); + + Subtask toEntity(SubtaskResponseDto dto); + + @Mapping(target = "id", ignore = true) // Не обновляем ID + @Mapping(target = "epic", ignore = true) + @Mapping(target = "startTime", ignore = true) // Оставляем startTime из базы + @Mapping(target = "endTime", ignore = true) + // endTime рассчитывается в @PreUpdate + void updateSubtaskFromDto(SubtaskRequestUpdatedDto dto, @MappingTarget Subtask subtask); +} diff --git a/service/src/main/java/service/task/manager/mapper/TaskMapper.java b/service/src/main/java/service/task/manager/mapper/TaskMapper.java new file mode 100644 index 0000000..c6062b4 --- /dev/null +++ b/service/src/main/java/service/task/manager/mapper/TaskMapper.java @@ -0,0 +1,32 @@ +package service.task.manager.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import service.task.manager.dto.task.TaskRequestCreatedDto; +import service.task.manager.dto.task.TaskRequestUpdatedDto; +import service.task.manager.dto.task.TaskResponseDto; +import service.task.manager.model.Task; + +@Mapper(componentModel = "spring") +public interface TaskMapper { + + // Маппинг из DTO в сущность Task + Task toEntity(TaskRequestCreatedDto taskRequestCreatedDto); + + // Маппинг из сущности Task в DTO ответа + TaskResponseDto toResponseDto(Task task); + + Task toEntity(TaskRequestUpdatedDto taskRequestUpdatedDto); + + Task toEntity(TaskResponseDto dto); + + TaskRequestUpdatedDto toTaskRequestUpdatedDto(Task task); + + // Метод для обновления существующей сущности + @Mapping(target = "id", ignore = true) // Не обновляем ID + @Mapping(target = "startTime", ignore = true) // Оставляем startTime из базы + @Mapping(target = "endTime", ignore = true) + // endTime рассчитывается в @PreUpdate + void updateTaskFromDto(TaskRequestUpdatedDto dto, @MappingTarget Task task); +} diff --git a/service/src/main/java/service/task/manager/model/Epic.java b/service/src/main/java/service/task/manager/model/Epic.java new file mode 100644 index 0000000..33aac29 --- /dev/null +++ b/service/src/main/java/service/task/manager/model/Epic.java @@ -0,0 +1,70 @@ +package service.task.manager.model; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.proxy.HibernateProxy; +import service.task.manager.model.enums.Status; +import service.task.manager.model.enums.TaskType; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Getter +@ToString(exclude = "subtasks") +@Setter +@Entity +@Table(name = "epic") +public class Epic { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToMany(mappedBy = "epic", cascade = CascadeType.ALL, orphanRemoval = true) + private List subtasks = new ArrayList<>(); + + @Column(nullable = false) + private String name; + + private String description; + + @Enumerated(EnumType.STRING) + private Status status; + + @Column(name = "start_time") + private LocalDateTime startTime; + + private Duration duration; + + @Column(name = "end_time") + private LocalDateTime endTime; + + @Setter(AccessLevel.NONE) + @Enumerated(EnumType.STRING) + private TaskType type = TaskType.EPIC; + + @PrePersist + @PreUpdate + public void calculateEndTime() { + if (startTime != null && duration != null) { + endTime = startTime.plus(duration); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Epic epic)) return false; + return Objects.equals(id, epic.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/model/HistoryEntry.java b/service/src/main/java/service/task/manager/model/HistoryEntry.java new file mode 100644 index 0000000..c0f6bf4 --- /dev/null +++ b/service/src/main/java/service/task/manager/model/HistoryEntry.java @@ -0,0 +1,14 @@ +package service.task.manager.model; + +import lombok.*; +import service.task.manager.model.enums.TaskType; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@ToString +public class HistoryEntry { + private TaskType type; + private Long id; +} diff --git a/service/src/main/java/service/task/manager/model/Subtask.java b/service/src/main/java/service/task/manager/model/Subtask.java new file mode 100644 index 0000000..b202776 --- /dev/null +++ b/service/src/main/java/service/task/manager/model/Subtask.java @@ -0,0 +1,67 @@ +package service.task.manager.model; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.proxy.HibernateProxy; +import service.task.manager.model.enums.Status; +import service.task.manager.model.enums.TaskType; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; + +@Getter +@Setter +@ToString +@RequiredArgsConstructor +@Entity +@Table(name = "subtask") +public class Subtask { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "epic_id") + private Epic epic; + + @Column(nullable = false) + private String name; + + private String description; + + @Enumerated(EnumType.STRING) + private Status status; + + @Column(name = "start_time") + private LocalDateTime startTime; + + @Column(name = "end_time") + private LocalDateTime endTime; + + private Duration duration; + + @Setter(AccessLevel.NONE) + @Enumerated(EnumType.STRING) + private TaskType type = TaskType.SUBTASK; + + @PrePersist + @PreUpdate + public void calculateEndTime() { + if (startTime != null && duration != null) { + endTime = startTime.plus(duration); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Subtask subtask)) return false; + return Objects.equals(id, subtask.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/model/Task.java b/service/src/main/java/service/task/manager/model/Task.java new file mode 100644 index 0000000..f133ea3 --- /dev/null +++ b/service/src/main/java/service/task/manager/model/Task.java @@ -0,0 +1,65 @@ +package service.task.manager.model; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.proxy.HibernateProxy; +import service.task.manager.model.enums.Status; +import service.task.manager.model.enums.TaskType; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; + +@Getter +@Setter +@ToString +@Entity +@Table(name = "task") +public class Task { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + private String description; + + @Enumerated(EnumType.STRING) + private Status status; + + @Column(name = "start_time") + private LocalDateTime startTime; + + @Column(name = "end_time") + private LocalDateTime endTime; + + private Duration duration; + + @Setter(AccessLevel.NONE) + @Enumerated(EnumType.STRING) + private TaskType type = TaskType.TASK; + + @PrePersist + @PreUpdate + public void calculateEndTime() { + if (startTime != null && duration != null) { + endTime = startTime.plus(duration); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Task task)) return false; + return Objects.equals(id, task.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } +} \ No newline at end of file diff --git a/src/task/manager/schedule/model/Status.java b/service/src/main/java/service/task/manager/model/enums/Status.java similarity index 58% rename from src/task/manager/schedule/model/Status.java rename to service/src/main/java/service/task/manager/model/enums/Status.java index daf6206..19818b4 100644 --- a/src/task/manager/schedule/model/Status.java +++ b/service/src/main/java/service/task/manager/model/enums/Status.java @@ -1,4 +1,4 @@ -package task.manager.schedule.model; +package service.task.manager.model.enums; public enum Status { NEW, diff --git a/src/task/manager/schedule/model/TaskType.java b/service/src/main/java/service/task/manager/model/enums/TaskType.java similarity index 58% rename from src/task/manager/schedule/model/TaskType.java rename to service/src/main/java/service/task/manager/model/enums/TaskType.java index 8b745a0..3866070 100644 --- a/src/task/manager/schedule/model/TaskType.java +++ b/service/src/main/java/service/task/manager/model/enums/TaskType.java @@ -1,4 +1,4 @@ -package task.manager.schedule.model; +package service.task.manager.model.enums; public enum TaskType { TASK, diff --git a/service/src/main/java/service/task/manager/repository/EpicRepository.java b/service/src/main/java/service/task/manager/repository/EpicRepository.java new file mode 100644 index 0000000..21f0e52 --- /dev/null +++ b/service/src/main/java/service/task/manager/repository/EpicRepository.java @@ -0,0 +1,10 @@ +package service.task.manager.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import service.task.manager.model.Epic; + +@Repository +public interface EpicRepository extends JpaRepository { + boolean existsByName(String name); +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/repository/SubtaskRepository.java b/service/src/main/java/service/task/manager/repository/SubtaskRepository.java new file mode 100644 index 0000000..c59c6fb --- /dev/null +++ b/service/src/main/java/service/task/manager/repository/SubtaskRepository.java @@ -0,0 +1,10 @@ +package service.task.manager.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import service.task.manager.model.Subtask; + +@Repository +public interface SubtaskRepository extends JpaRepository { + boolean existsByName(String name); +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/repository/TaskRepository.java b/service/src/main/java/service/task/manager/repository/TaskRepository.java new file mode 100644 index 0000000..5b63d35 --- /dev/null +++ b/service/src/main/java/service/task/manager/repository/TaskRepository.java @@ -0,0 +1,10 @@ +package service.task.manager.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import service.task.manager.model.Task; + +@Repository +public interface TaskRepository extends JpaRepository { + boolean existsByName(String name); +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/service/EpicService.java b/service/src/main/java/service/task/manager/service/EpicService.java new file mode 100644 index 0000000..67894f2 --- /dev/null +++ b/service/src/main/java/service/task/manager/service/EpicService.java @@ -0,0 +1,19 @@ +package service.task.manager.service; + +import service.task.manager.dto.epic.EpicRequestCreatedDto; +import service.task.manager.dto.epic.EpicRequestUpdatedDto; +import service.task.manager.dto.epic.EpicResponseDto; + +import java.util.List; + +public interface EpicService { + void create(EpicRequestCreatedDto dto); + + EpicResponseDto update(EpicRequestUpdatedDto dto); + + EpicResponseDto findById(Long id); + + List findAll(); + + void delete(Long id); +} diff --git a/service/src/main/java/service/task/manager/service/HistoryService.java b/service/src/main/java/service/task/manager/service/HistoryService.java new file mode 100644 index 0000000..beadd90 --- /dev/null +++ b/service/src/main/java/service/task/manager/service/HistoryService.java @@ -0,0 +1,13 @@ +package service.task.manager.service; + +import service.task.manager.model.HistoryEntry; +import service.task.manager.model.enums.TaskType; + +import java.util.List; + +public interface HistoryService { + + void addToHistory(TaskType type, Long id); + + List getHistory(); +} diff --git a/service/src/main/java/service/task/manager/service/SubtaskService.java b/service/src/main/java/service/task/manager/service/SubtaskService.java new file mode 100644 index 0000000..7ebf70b --- /dev/null +++ b/service/src/main/java/service/task/manager/service/SubtaskService.java @@ -0,0 +1,19 @@ +package service.task.manager.service; + +import service.task.manager.dto.subtask.SubtaskRequestCreatedDto; +import service.task.manager.dto.subtask.SubtaskRequestUpdatedDto; +import service.task.manager.dto.subtask.SubtaskResponseDto; + +import java.util.List; + +public interface SubtaskService { + void create(SubtaskRequestCreatedDto dto); + + SubtaskResponseDto update(SubtaskRequestUpdatedDto dto); + + SubtaskResponseDto findById(Long id); + + List findAll(); + + void delete(Long id); +} diff --git a/service/src/main/java/service/task/manager/service/TaskService.java b/service/src/main/java/service/task/manager/service/TaskService.java new file mode 100644 index 0000000..68d1a29 --- /dev/null +++ b/service/src/main/java/service/task/manager/service/TaskService.java @@ -0,0 +1,19 @@ +package service.task.manager.service; + +import service.task.manager.dto.task.TaskRequestCreatedDto; +import service.task.manager.dto.task.TaskRequestUpdatedDto; +import service.task.manager.dto.task.TaskResponseDto; + +import java.util.List; + +public interface TaskService { + void create(TaskRequestCreatedDto dto); + + TaskResponseDto update(TaskRequestUpdatedDto dto); + + TaskResponseDto findById(Long id); + + List findAll(); + + void delete(Long id); +} diff --git a/service/src/main/java/service/task/manager/service/impl/EpicServiceImpl.java b/service/src/main/java/service/task/manager/service/impl/EpicServiceImpl.java new file mode 100644 index 0000000..9ffd425 --- /dev/null +++ b/service/src/main/java/service/task/manager/service/impl/EpicServiceImpl.java @@ -0,0 +1,146 @@ +package service.task.manager.service.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import service.task.manager.dto.epic.EpicRequestCreatedDto; +import service.task.manager.dto.epic.EpicRequestUpdatedDto; +import service.task.manager.dto.epic.EpicResponseDto; +import service.task.manager.error.ConflictException; +import service.task.manager.error.NotFoundException; +import service.task.manager.mapper.EpicMapper; +import service.task.manager.model.Epic; +import service.task.manager.model.enums.Status; +import service.task.manager.repository.EpicRepository; +import service.task.manager.service.EpicService; +import service.task.manager.service.HistoryService; + +import java.util.List; + +/** + * Service implementation for managing epics. + * Provides methods to create, update, retrieve, and delete epics. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class EpicServiceImpl implements EpicService { + private final EpicRepository repository; + private final EpicMapper mapper; + private final HistoryService history; + + /** + * Creates a new epic based on the provided DTO. + *

+ * This method checks if an epic with the same name already exists in the database. + * If it does, a {@link ConflictException} is thrown. Otherwise, the epic is saved + * with a status of {@link Status#NEW} and the end time calculated based on its start time + * and duration. + *

+ * @param dto The DTO containing epic creation data (must include name, start time, and duration). + * @throws ConflictException If an epic with the same name already exists. + */ + @Transactional + @Override + public void create(EpicRequestCreatedDto dto) { + log.info("Attempting to create epic with name: {}", dto.name()); + if (repository.existsByName(dto.name())) { + log.warn("Epic creation failed: Epic with name {} already exists", dto.name()); + throw new ConflictException("Epic with name " + dto.name() + " already exists"); + } + + Epic epic = addEndTimeEpicAndStatus(mapper.toEntity(dto)); + repository.save(epic); + log.info("Epic created successfully with name: {}", dto.name()); + } + + /** + * Updates an existing epic with the provided data. + *

+ * This method retrieves the epic by its ID, updates its fields using the provided DTO, + * and saves the changes to the database. + *

+ * @param dto The DTO containing updated epic data (must include epic ID). + * @return The updated epic as a DTO. + * @throws NotFoundException If the epic with the specified ID does not exist. + */ + @Transactional + @Override + public EpicResponseDto update(EpicRequestUpdatedDto dto) { + log.info("Attempting to update epic with ID: {}", dto.id()); + Epic existingEpic = mapper.toEntity(findById(dto.id())); + mapper.updateTaskFromDto(dto, existingEpic); + Epic updatedEpic = repository.save(existingEpic); + log.info("Epic with ID {} updated successfully", updatedEpic.getId()); + return mapper.toResponseDto(updatedEpic); + } + + /** + * Retrieves an epic by its ID. + * @param id The ID of the epic to retrieve. + * @return The epic as a DTO. + * @throws NotFoundException If the epic with the specified ID does not exist. + */ + @Transactional(readOnly = true) + @Override + public EpicResponseDto findById(Long id) { + log.info("Fetching epic with ID: {}", id); + EpicResponseDto epic = repository.findById(id) + .stream() + .map(mapper::toResponseDto) + .findFirst() + .orElseThrow(() -> { + log.warn("Epic with ID {} not found", id); + return new NotFoundException("Epic with ID " + id + " not found"); + }); + history.addToHistory(epic.type(), epic.id()); + log.info("Epic with ID {} retrieved successfully", id); + return epic; + } + + /** + * Retrieves all epics. + * @return A list of all epics as DTOs. + */ + @Transactional(readOnly = true) + @Override + public List findAll() { + log.info("Fetching all epics"); + List epics = repository.findAll() + .stream() + .map(mapper::toResponseDto) + .toList(); + log.info("Retrieved {} epics", epics.size()); + return epics; + } + + /** + * Deletes an epic by its ID. + *

+ * This method deletes the epic directly from the database. If the epic does not exist, + * no exception is thrown as per the current implementation. + *

+ * @param id The ID of the epic to delete. + */ + @Transactional + @Override + public void delete(Long id) { + log.info("Attempting to delete epic with ID: {}", id); + repository.deleteById(id); + log.info("Epic with ID {} deleted successfully", id); + } + + /** + * Sets the status to NEW and calculates the end time for the given epic. + * @param epic The epic to modify. + * @return The modified epic with updated status and end time. + */ + private Epic addEndTimeEpicAndStatus(Epic epic) { + log.debug("Setting status and calculating end time for epic"); + epic.calculateEndTime(); + epic.setStatus(Status.NEW); + log.debug("Epic status set to NEW and end time calculated"); + return epic; + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/service/impl/HistoryServiceImpl.java b/service/src/main/java/service/task/manager/service/impl/HistoryServiceImpl.java new file mode 100644 index 0000000..d020ac6 --- /dev/null +++ b/service/src/main/java/service/task/manager/service/impl/HistoryServiceImpl.java @@ -0,0 +1,83 @@ +package service.task.manager.service.impl; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.ListOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import service.task.manager.model.HistoryEntry; +import service.task.manager.model.enums.TaskType; +import service.task.manager.service.HistoryService; + +import java.util.ArrayList; +import java.util.List; + +/** + * Implementation of the service for managing the history of accesses to tasks (Task, Epic, Subtask) using Redis. + * The history stores the last 10 records of calls to the findBy(long id) method for tasks, epics, and subtasks. + * If the number of records exceeds the limit, the oldest record is removed. + */ +@Slf4j +@Service +public class HistoryServiceImpl implements HistoryService { + + private static final String HISTORY_KEY = "history"; + private static final int HISTORY_SIZE = 10; + + private final RedisTemplate redisTemplate; + private final ListOperations listOps; + + @Autowired + public HistoryServiceImpl(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + this.listOps = redisTemplate.opsForList(); + } + + /** + * Adds a record to the history of findBy(long id) method calls. + * If the history size exceeds the limit (10 records), the oldest record is removed. + * + * @param type the type of the task (TASK, EPIC, SUBTASK) + * @param id the identifier of the task + */ + @Override + public void addToHistory(TaskType type, Long id) { + try { + HistoryEntry entry = new HistoryEntry(type, id); + log.info("Adding entry to history: type={}, id={}", type, id); + listOps.rightPush(HISTORY_KEY, entry); + Long size = listOps.size(HISTORY_KEY); + if (size != null && size > HISTORY_SIZE) { + log.debug("History size exceeded limit ({}), removing oldest entry", HISTORY_SIZE); + listOps.leftPop(HISTORY_KEY); + } + } catch (Exception e) { + log.error("Failed to add entry to history: type={}, id={}, error={}", type, id, e.getMessage(), e); + } + } + + /** + * Retrieves the list of entries from the history of method calls. + * If the history is empty or an error occurs while retrieving data, an empty list is returned. + * + * @return the list of history entries + */ + @Override + public List getHistory() { + try { + log.info("Retrieving call history"); + List history = listOps.range(HISTORY_KEY, 0, -1); + if (history == null) { + log.warn("History is empty or failed to retrieve data from Redis"); + return new ArrayList<>(); + } + log.debug("Successfully retrieved {} entries from history", history.size()); + return history; + } catch (Exception e) { + log.error("Failed to retrieve history: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/service/impl/SubtaskServiceImpl.java b/service/src/main/java/service/task/manager/service/impl/SubtaskServiceImpl.java new file mode 100644 index 0000000..0a2d47e --- /dev/null +++ b/service/src/main/java/service/task/manager/service/impl/SubtaskServiceImpl.java @@ -0,0 +1,154 @@ +package service.task.manager.service.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import service.task.manager.dto.epic.EpicResponseDto; +import service.task.manager.dto.subtask.SubtaskRequestCreatedDto; +import service.task.manager.dto.subtask.SubtaskRequestUpdatedDto; +import service.task.manager.dto.subtask.SubtaskResponseDto; +import service.task.manager.error.ConflictException; +import service.task.manager.error.NotFoundException; +import service.task.manager.mapper.EpicMapper; +import service.task.manager.mapper.SubtaskMapper; +import service.task.manager.model.Epic; +import service.task.manager.model.Subtask; +import service.task.manager.model.enums.Status; +import service.task.manager.repository.SubtaskRepository; +import service.task.manager.service.EpicService; +import service.task.manager.service.HistoryService; +import service.task.manager.service.SubtaskService; + +import java.util.List; + +/** + * Service implementation for managing subtasks. + * Provides methods to create, update, retrieve, and delete subtasks associated with epics. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SubtaskServiceImpl implements SubtaskService { + private final SubtaskRepository repository; + private final EpicService epicService; + private final SubtaskMapper mapper; + private final EpicMapper epicMapper; + private final HistoryService history; + + /** + * Creates a new subtask based on the provided DTO. + *

+ * This method verifies the existence of the associated epic and checks for duplicate subtask names. + * If the epic does not exist, a {@link NotFoundException} will be thrown by the EpicService. + * If a subtask with the same name already exists, a {@link ConflictException} is thrown. + *

+ * @param dto The DTO containing subtask creation data (must include name and epic ID). + * @throws ConflictException If a subtask with the same name already exists. + * @throws NotFoundException If the associated epic does not exist. + */ + @Transactional + @Override + public void create(SubtaskRequestCreatedDto dto) { + log.info("Attempting to create subtask with name: {}", dto.name()); + + EpicResponseDto epicDto = epicService.findById(dto.epicId()); + Epic epic = epicMapper.toEntity(epicDto); + + if (repository.existsByName(dto.name())) { + log.warn("Subtask creation failed: Subtask with name {} already exists", dto.name()); + throw new ConflictException("Subtask with name " + dto.name() + " already exists"); + } + + Subtask subtask = addEndTimeSubtaskAndStatus(mapper.toEntity(dto)); + subtask.setEpic(epic); + repository.save(subtask); + log.info("Subtask created successfully with name: {}", dto.name()); + } + + /** + * Updates an existing subtask with the provided data. + *

+ * This method retrieves the subtask by its ID, updates its fields using the provided DTO, + * and saves the changes to the database. + *

+ * @param dto The DTO containing updated subtask data (must include subtask ID). + * @return The updated subtask as a DTO. + * @throws NotFoundException If the subtask with the specified ID does not exist. + */ + @Transactional + @Override + public SubtaskResponseDto update(SubtaskRequestUpdatedDto dto) { + log.info("Attempting to update subtask with ID: {}", dto.id()); + + Subtask existingSubtask = mapper.toEntity(findById(dto.id())); + mapper.updateSubtaskFromDto(dto, existingSubtask); + Subtask updatedSubtask = repository.save(existingSubtask); + log.info("Subtask with ID {} updated successfully", updatedSubtask.getId()); + return mapper.toResponseDto(updatedSubtask); + } + + /** + * Retrieves a subtask by its ID. + * @param id The ID of the subtask to retrieve. + * @return The subtask as a DTO. + * @throws NotFoundException If the subtask with the specified ID does not exist. + */ + @Transactional(readOnly = true) + @Override + public SubtaskResponseDto findById(Long id) { + log.info("Fetching subtask with ID: {}", id); + SubtaskResponseDto subtask = repository.findById(id).stream() + .map(mapper::toResponseDto) + .findFirst() + .orElseThrow(() -> { + log.warn("Subtask with ID {} not found", id); + return new NotFoundException("Subtask with ID " + id + " not found"); + }); + history.addToHistory(subtask.type(),subtask.id()); + log.info("Subtask with ID {} retrieved successfully", id); + return subtask; + } + + /** + * Retrieves all subtasks. + * @return A list of all subtasks as DTOs. + */ + @Transactional(readOnly = true) + @Override + public List findAll() { + log.info("Fetching all subtasks"); + List subtasks = repository.findAll().stream() + .map(mapper::toResponseDto) + .toList(); + log.info("Retrieved {} subtasks", subtasks.size()); + return subtasks; + } + + /** + * Deletes a subtask by its ID. + * @param id The ID of the subtask to delete. + * @throws NotFoundException If the subtask with the specified ID does not exist. + */ + @Transactional + @Override + public void delete(Long id) { + log.info("Attempting to delete subtask with ID: {}", id); + findById(id); // Проверяет существование + repository.deleteById(id); + log.info("Subtask with ID {} deleted successfully", id); + } + + /** + * Sets the status to NEW and calculates the end time for the given subtask. + * @param subtask The subtask to modify. + * @return The modified subtask with updated status and end time. + */ + private Subtask addEndTimeSubtaskAndStatus(Subtask subtask) { + log.debug("Setting status and calculating end time for subtask"); + subtask.calculateEndTime(); + subtask.setStatus(Status.NEW); + log.debug("Subtask status set to NEW and end time calculated"); + return subtask; + } +} \ No newline at end of file diff --git a/service/src/main/java/service/task/manager/service/impl/TaskServiceImpl.java b/service/src/main/java/service/task/manager/service/impl/TaskServiceImpl.java new file mode 100644 index 0000000..1ba09da --- /dev/null +++ b/service/src/main/java/service/task/manager/service/impl/TaskServiceImpl.java @@ -0,0 +1,132 @@ +package service.task.manager.service.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import service.task.manager.dto.task.TaskRequestCreatedDto; +import service.task.manager.dto.task.TaskRequestUpdatedDto; +import service.task.manager.dto.task.TaskResponseDto; +import service.task.manager.error.ConflictException; +import service.task.manager.error.NotFoundException; +import service.task.manager.mapper.TaskMapper; +import service.task.manager.model.Task; +import service.task.manager.model.enums.Status; +import service.task.manager.repository.TaskRepository; +import service.task.manager.service.HistoryService; +import service.task.manager.service.TaskService; + +import java.util.List; + +/** + * Service implementation for managing tasks. + * Provides methods to create, update, retrieve, and delete tasks. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskServiceImpl implements TaskService { + private final TaskRepository repository; + private final TaskMapper mapper; + private final HistoryService history; + + /** + * Creates a new task based on the provided DTO. + * @param dto The DTO containing task creation data. + * @throws ConflictException If a task with the same name already exists. + */ + @Transactional + @Override + public void create(TaskRequestCreatedDto dto) { + log.info("Attempting to create task with name: {}", dto.name()); + if (repository.existsByName(dto.name())) { + log.warn("Task creation failed: Task with name {} already exists", dto.name()); + throw new ConflictException("Task with name " + dto.name() + " already exists"); + } + + Task task = addEndTimeTaskAndStatus(mapper.toEntity(dto)); + repository.save(task); + log.info("Task created successfully with name: {}", dto.name()); + } + + /** + * Updates an existing task with the provided data. + * @param dto The DTO containing updated task data. + * @return The updated task as a DTO. + * @throws NotFoundException If the task with the specified ID does not exist. + */ + @Transactional + @Override + public TaskResponseDto update(TaskRequestUpdatedDto dto) { + log.info("Attempting to update task with ID: {}", dto.id()); + Task existingTask = mapper.toEntity(findById(dto.id())); + mapper.updateTaskFromDto(dto, existingTask); + Task updatedTask = repository.save(existingTask); + log.info("Task with ID {} updated successfully", updatedTask.getId()); + return mapper.toResponseDto(updatedTask); + } + + /** + * Retrieves a task by its ID. + * @param id The ID of the task to retrieve. + * @return The task as a DTO. + * @throws NotFoundException If the task with the specified ID does not exist. + */ + @Transactional(readOnly = true) + @Override + public TaskResponseDto findById(Long id) { + log.info("Fetching task with ID: {}", id); + TaskResponseDto task = repository.findById(id).stream() + .map(mapper::toResponseDto) + .findFirst() + .orElseThrow(() -> { + log.warn("Task with ID {} not found", id); + return new NotFoundException("Task with ID " + id + " not found"); + }); + history.addToHistory(task.type(),task.id()); + log.info("Task with ID {} retrieved successfully", id); + return task; + } + + /** + * Retrieves all tasks. + * @return A list of all tasks as DTOs. + */ + @Transactional(readOnly = true) + @Override + public List findAll() { + log.info("Fetching all tasks"); + List tasks = repository.findAll().stream() + .map(mapper::toResponseDto) + .toList(); + log.info("Retrieved {} tasks", tasks.size()); + return tasks; + } + + /** + * Deletes a task by its ID. + * @param id The ID of the task to delete. + * @throws NotFoundException If the task with the specified ID does not exist. + */ + @Transactional + @Override + public void delete(Long id) { + log.info("Attempting to delete task with ID: {}", id); + findById(id); // Проверяет существование + repository.deleteById(id); + log.info("Task with ID {} deleted successfully", id); + } + + /** + * Sets the status to NEW and calculates the end time for the given task. + * @param task The task to modify. + * @return The modified task with updated status and end time. + */ + private Task addEndTimeTaskAndStatus(Task task) { + log.debug("Setting status and calculating end time for task"); + task.calculateEndTime(); + task.setStatus(Status.NEW); + log.debug("Task status set to NEW and end time calculated"); + return task; + } +} \ No newline at end of file diff --git a/service/src/main/resources/application.yml b/service/src/main/resources/application.yml new file mode 100644 index 0000000..8530f08 --- /dev/null +++ b/service/src/main/resources/application.yml @@ -0,0 +1,30 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + username: sa + password: + driver-class-name: org.h2.Driver + sql: + init: + mode: always + schema-locations: classpath:schema.sql + jpa: + properties: + hibernate.dialect: org.hibernate.dialect.H2Dialect + data: + redis: + host: localhost + port: 6379 + +# Путь к Swagger UI +springdoc: + swagger-ui: + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha + info: + title: Task Manager API + version: 1.0.0 + api-docs: + enabled: true + path: /v3/api-docs \ No newline at end of file diff --git a/service/src/main/resources/schema.sql b/service/src/main/resources/schema.sql new file mode 100644 index 0000000..4135a0a --- /dev/null +++ b/service/src/main/resources/schema.sql @@ -0,0 +1,45 @@ +-- Таблица для Epic +CREATE TABLE epic +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(50), + start_time TIMESTAMP, + duration BIGINT, -- Хранится в секундах + end_time TIMESTAMP, + type VARCHAR(50) NOT NULL +); + +-- Таблица для Subtask +CREATE TABLE subtask +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + epic_id BIGINT, + name VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(50), + start_time TIMESTAMP, + duration BIGINT, -- Хранится в секундах + end_time TIMESTAMP, + type VARCHAR(50) NOT NULL, + FOREIGN KEY (epic_id) REFERENCES epic (id) ON DELETE CASCADE +); + +-- Таблица для Task +CREATE TABLE task +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(50), + start_time TIMESTAMP, + duration BIGINT, -- Хранится в секундах + end_time TIMESTAMP, + type VARCHAR(50) NOT NULL +); + +-- Опционально: Добавление индексов для оптимизации +CREATE INDEX idx_epic_status ON epic (status); +CREATE INDEX idx_subtask_epic_id ON subtask (epic_id); +CREATE INDEX idx_task_status ON task (status); \ No newline at end of file diff --git a/service/src/test/java/service/task/manager/controller/EpicControllerTest.java b/service/src/test/java/service/task/manager/controller/EpicControllerTest.java new file mode 100644 index 0000000..582d02c --- /dev/null +++ b/service/src/test/java/service/task/manager/controller/EpicControllerTest.java @@ -0,0 +1,253 @@ +package service.task.manager.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import service.task.manager.dto.epic.EpicRequestCreatedDto; +import service.task.manager.dto.epic.EpicRequestUpdatedDto; +import service.task.manager.dto.epic.EpicResponseDto; +import service.task.manager.error.ConflictException; +import service.task.manager.error.NotFoundException; +import service.task.manager.model.enums.Status; +import service.task.manager.model.enums.TaskType; +import service.task.manager.service.EpicService; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(EpicController.class) +class EpicControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private EpicService epicService; + + @Autowired + private ObjectMapper objectMapper; + + private EpicResponseDto epicResponseDto; + + @BeforeEach + void setUp() { + epicResponseDto = new EpicResponseDto( + 1L, + Collections.emptyList(), // subtasks + "Test Epic", + "Test Description", + Status.NEW, + LocalDateTime.of(2025, 4, 27, 10, 0), + Duration.ofHours(24), + LocalDateTime.of(2025, 4, 28, 10, 0), + TaskType.EPIC + ); + } + + @Test + void create_ValidEpic_ReturnsCreated() throws Exception { + EpicRequestCreatedDto requestDto = new EpicRequestCreatedDto( + "Test Epic", + "Test Description", + LocalDateTime.of(2025, 4, 27, 10, 0), + Duration.ofHours(24) + ); + + doNothing().when(epicService).create(any(EpicRequestCreatedDto.class)); + + mockMvc.perform(post("/epic") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isCreated()) + .andExpect(content().string("")); + + verify(epicService, times(1)).create(any(EpicRequestCreatedDto.class)); + } + + @Test + void create_DuplicateEpic_ReturnsConflict() throws Exception { + EpicRequestCreatedDto requestDto = new EpicRequestCreatedDto( + "Duplicate Epic", + "Test Description", + LocalDateTime.of(2025, 4, 27, 10, 0), + Duration.ofHours(24) + ); + + doThrow(new ConflictException("Epic with the same name already exists")) + .when(epicService).create(any(EpicRequestCreatedDto.class)); + + mockMvc.perform(post("/epic") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").value("Epic with the same name already exists")); + + verify(epicService, times(1)).create(any(EpicRequestCreatedDto.class)); + } + + @Test + void create_InvalidEpic_ReturnsBadRequest() throws Exception { + EpicRequestCreatedDto requestDto = new EpicRequestCreatedDto( + "", // Пустое имя, что нарушает валидацию + "Test Description", + LocalDateTime.of(2025, 4, 27, 10, 0), + Duration.ofHours(24) + ); + + mockMvc.perform(post("/epic") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.name").value("blank name")); + + verify(epicService, never()).create(any(EpicRequestCreatedDto.class)); + } + + @Test + void update_ValidEpic_ReturnsOk() throws Exception { + EpicRequestUpdatedDto requestDto = new EpicRequestUpdatedDto( + 1L, + "Updated Epic", + "Updated Description", + Status.IN_PROGRESS, + Duration.ofHours(48) + ); + + when(epicService.update(any(EpicRequestUpdatedDto.class))).thenReturn(epicResponseDto); + + mockMvc.perform(put("/epic") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.name").value("Test Epic")) + .andExpect(jsonPath("$.status").value("NEW")); + + verify(epicService, times(1)).update(any(EpicRequestUpdatedDto.class)); + } + + @Test + void update_EpicNotFound_ReturnsNotFound() throws Exception { + EpicRequestUpdatedDto requestDto = new EpicRequestUpdatedDto( + 999L, + "Updated Epic", + "Updated Description", + Status.IN_PROGRESS, + Duration.ofHours(48) + ); + + when(epicService.update(any(EpicRequestUpdatedDto.class))) + .thenThrow(new NotFoundException("Epic not found")); + + mockMvc.perform(put("/epic") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("Epic not found")); + + verify(epicService, times(1)).update(any(EpicRequestUpdatedDto.class)); + } + + @Test + void findById_ValidId_ReturnsOk() throws Exception { + when(epicService.findById(1L)).thenReturn(epicResponseDto); + + mockMvc.perform(get("/epic/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.name").value("Test Epic")) + .andExpect(jsonPath("$.status").value("NEW")) + .andExpect(jsonPath("$.type").value("EPIC")); + + verify(epicService, times(1)).findById(1L); + } + + @Test + void findById_EpicNotFound_ReturnsNotFound() throws Exception { + when(epicService.findById(999L)).thenThrow(new NotFoundException("Epic not found")); + + mockMvc.perform(get("/epic/999")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("Epic not found")); + + verify(epicService, times(1)).findById(999L); + } + + @Test + void findById_InvalidId_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/epic/-1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("id must be positive")); + + verify(epicService, never()).findById(anyLong()); + } + + @Test + void findAll_ReturnsOk() throws Exception { + List epics = List.of(epicResponseDto); + when(epicService.findAll()).thenReturn(epics); + + mockMvc.perform(get("/epic")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1)) + .andExpect(jsonPath("$[0].name").value("Test Epic")) + .andExpect(jsonPath("$[0].status").value("NEW")) + .andExpect(jsonPath("$[0].type").value("EPIC")); + + verify(epicService, times(1)).findAll(); + } + + @Test + void findAll_EmptyList_ReturnsOk() throws Exception { + when(epicService.findAll()).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/epic")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + + verify(epicService, times(1)).findAll(); + } + + @Test + void delete_ValidId_ReturnsNoContent() throws Exception { + doNothing().when(epicService).delete(1L); + + mockMvc.perform(delete("/epic/1")) + .andExpect(status().isNoContent()); + + verify(epicService, times(1)).delete(1L); + } + + @Test + void delete_EpicNotFound_ReturnsNotFound() throws Exception { + doThrow(new NotFoundException("Epic not found")).when(epicService).delete(999L); + + mockMvc.perform(delete("/epic/999")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("Epic not found")); + + verify(epicService, times(1)).delete(999L); + } + + @Test + void delete_InvalidId_ReturnsBadRequest() throws Exception { + mockMvc.perform(delete("/epic/-1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("id must be positive")); + + verify(epicService, never()).delete(anyLong()); + } +} \ No newline at end of file diff --git a/service/src/test/java/service/task/manager/controller/SubtaskControllerTest.java b/service/src/test/java/service/task/manager/controller/SubtaskControllerTest.java new file mode 100644 index 0000000..3f6a0a3 --- /dev/null +++ b/service/src/test/java/service/task/manager/controller/SubtaskControllerTest.java @@ -0,0 +1,256 @@ +package service.task.manager.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import service.task.manager.dto.subtask.SubtaskRequestCreatedDto; +import service.task.manager.dto.subtask.SubtaskRequestUpdatedDto; +import service.task.manager.dto.subtask.SubtaskResponseDto; +import service.task.manager.error.ConflictException; +import service.task.manager.error.ErrorHandler; +import service.task.manager.error.NotFoundException; +import service.task.manager.model.enums.Status; +import service.task.manager.model.enums.TaskType; +import service.task.manager.service.SubtaskService; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(controllers = {SubtaskController.class, ErrorHandler.class}) +class SubtaskControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private SubtaskService subtaskService; + + private final LocalDateTime testTime = LocalDateTime.of(2025, 4, 27, 10, 0); + private final Duration testDuration = Duration.ofHours(24); + + // CREATE tests + @Test + void createSubtask_ValidRequest_ReturnsCreated() throws Exception { + SubtaskRequestCreatedDto request = new SubtaskRequestCreatedDto( + 1L, + "Valid Subtask", + "Valid Description", + testTime, + testDuration + ); + + mockMvc.perform(post("/subtask") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(content().string("")); + + verify(subtaskService).create(request); + } + + @Test + void createSubtask_ConflictName_ReturnsConflict() throws Exception { + SubtaskRequestCreatedDto request = new SubtaskRequestCreatedDto( + 1L, + "Duplicate Subtask", + "Description", + testTime, + testDuration + ); + + doThrow(new ConflictException("Subtask with name Duplicate Subtask already exists")) + .when(subtaskService).create(request); // Используем конкретный request + + mockMvc.perform(post("/subtask") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").value("Subtask with name Duplicate Subtask already exists")); + } + + // UPDATE tests + @Test + void updateSubtask_ValidRequest_ReturnsOk() throws Exception { + SubtaskRequestUpdatedDto request = new SubtaskRequestUpdatedDto( + 1L, + "Updated Subtask", + "Updated Description", + Status.IN_PROGRESS, + Duration.ofHours(48) + ); + + SubtaskResponseDto responseDto = new SubtaskResponseDto( + 1L, + 1L, + "Updated Subtask", + "Updated Description", + Status.IN_PROGRESS, + testTime, + testTime.plus(Duration.ofHours(48)), + Duration.ofHours(48), + TaskType.SUBTASK + ); + + when(subtaskService.update(any())).thenReturn(responseDto); + + mockMvc.perform(put("/subtask") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.name").value("Updated Subtask")) + .andDo(result -> System.out.println("Response: " + result.getResponse().getContentAsString())); + + verify(subtaskService).update(request); // Проверяем вызов сервиса + } + + @Test + void updateSubtask_NotFound_ReturnsNotFound() throws Exception { + SubtaskRequestUpdatedDto request = new SubtaskRequestUpdatedDto( + 999L, + "Non-existent Subtask", + "Description", + Status.NEW, + testDuration + ); + + when(subtaskService.update(any())) + .thenThrow(new NotFoundException("Subtask with ID 999 not found")); + + mockMvc.perform(put("/subtask") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("Subtask with ID 999 not found")); + } + + // GET tests + @Test + void getSubtask_ValidId_ReturnsOk() throws Exception { + SubtaskResponseDto responseDto = new SubtaskResponseDto( + 1L, + 1L, + "Test Subtask", + "Test Description", + Status.NEW, + testTime, + testTime.plus(testDuration), + testDuration, + TaskType.SUBTASK + ); + + when(subtaskService.findById(1L)).thenReturn(responseDto); + + mockMvc.perform(get("/subtask/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.epicId").value(1L)) + .andExpect(jsonPath("$.name").value("Test Subtask")) + .andExpect(jsonPath("$.description").value("Test Description")) + .andExpect(jsonPath("$.status").value("NEW")) + .andExpect(jsonPath("$.startTime").value("2025-04-27T10:00:00")) + .andExpect(jsonPath("$.endTime").value("2025-04-28T10:00:00")) + .andExpect(jsonPath("$.duration").value("PT24H")) + .andExpect(jsonPath("$.type").value("SUBTASK")) + .andDo(result -> System.out.println("Response: " + result.getResponse().getContentAsString())); + } + + @Test + void getSubtask_InvalidId_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/subtask/-1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("id must be positive")) + .andDo(result -> System.out.println("Response: " + result.getResponse().getContentAsString())); + } + + @Test + void getSubtask_NotFound_ReturnsNotFound() throws Exception { + when(subtaskService.findById(999L)) + .thenThrow(new NotFoundException("Subtask with ID 999 not found")); + + mockMvc.perform(get("/subtask/999")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("Subtask with ID 999 not found")); + } + + // GET ALL tests + @Test + void getAllSubtasks_ReturnsOk() throws Exception { + List subtasks = Collections.singletonList( + new SubtaskResponseDto( + 1L, + 1L, + "Subtask", + "Description", + Status.NEW, + testTime, + testTime.plus(testDuration), + testDuration, + TaskType.SUBTASK + ) + ); + + when(subtaskService.findAll()).thenReturn(subtasks); + + mockMvc.perform(get("/subtask")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1L)) + .andDo(result -> System.out.println("Response: " + result.getResponse().getContentAsString())); + } + + // DELETE tests + @Test + void deleteSubtask_ValidId_ReturnsNoContent() throws Exception { + mockMvc.perform(delete("/subtask/1")) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); // Проверяем, что тело ответа пустое + + verify(subtaskService).delete(1L); + } + + @Test + void deleteSubtask_NotFound_ReturnsNotFound() throws Exception { + doThrow(new NotFoundException("Subtask with ID 999 not found")) + .when(subtaskService).delete(999L); + + mockMvc.perform(delete("/subtask/999")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("Subtask with ID 999 not found")); + } + + // Validation tests + @Test + void createSubtask_InvalidRequest_ReturnsBadRequest() throws Exception { + SubtaskRequestCreatedDto invalidRequest = new SubtaskRequestCreatedDto( + null, // Missing epicId + "", // Blank name + "", // Blank description + null, // Missing startTime + null // Missing duration + ); + + mockMvc.perform(post("/subtask") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.epicId").value("null epic ID")) + .andExpect(jsonPath("$.name").value("blank name")) + .andExpect(jsonPath("$.description").value("blank description")) + .andExpect(jsonPath("$.startTime").value("null start time")) + .andExpect(jsonPath("$.duration").value("null duration")); + } +} \ No newline at end of file diff --git a/service/src/test/java/service/task/manager/controller/TaskControllerTest.java b/service/src/test/java/service/task/manager/controller/TaskControllerTest.java new file mode 100644 index 0000000..efefd7f --- /dev/null +++ b/service/src/test/java/service/task/manager/controller/TaskControllerTest.java @@ -0,0 +1,223 @@ +package service.task.manager.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import service.task.manager.dto.task.TaskRequestCreatedDto; +import service.task.manager.dto.task.TaskRequestUpdatedDto; +import service.task.manager.dto.task.TaskResponseDto; +import service.task.manager.error.ErrorHandler; +import service.task.manager.error.NotFoundException; +import service.task.manager.model.enums.Status; +import service.task.manager.model.enums.TaskType; +import service.task.manager.service.TaskService; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = {TaskController.class, ErrorHandler.class}) +class TaskControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private TaskService taskService; + + private final LocalDateTime testTime = LocalDateTime.of(2025, 4, 27, 10, 0); + private final Duration testDuration = Duration.ofHours(24); + + // CREATE tests + @Test + void createTask_ValidRequest_ReturnsCreated() throws Exception { + TaskRequestCreatedDto request = new TaskRequestCreatedDto( + "Valid Task", + "Valid Description", + testTime, + testDuration + ); + + mockMvc.perform(post("/task") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + + verify(taskService).create(request); + } + + @Test + void createTask_InvalidRequest_ReturnsBadRequest() throws Exception { + TaskRequestCreatedDto invalidRequest = new TaskRequestCreatedDto( + "", // Invalid blank name + "", // Invalid blank description + null, // Missing start time + null // Missing duration + ); + + mockMvc.perform(post("/task") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } + @Test + void updateTask_ValidRequest_ReturnsOk() throws Exception { + TaskRequestUpdatedDto request = new TaskRequestUpdatedDto( + 1L, + "Updated Task", + "Updated Description", + Status.IN_PROGRESS, + Duration.ofHours(48) + ); + + TaskResponseDto responseDto = new TaskResponseDto( + 1L, + "Updated Task", + "Updated Description", + Status.IN_PROGRESS, + testTime, + testTime.plus(Duration.ofHours(48)), + Duration.ofHours(48), + TaskType.TASK + ); + + when(taskService.update(any(TaskRequestUpdatedDto.class))).thenReturn(responseDto); + + mockMvc.perform(put("/task") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.name").value("Updated Task")); + } + + @Test + void updateTask_InvalidId_ReturnsBadRequest() throws Exception { + TaskRequestUpdatedDto invalidRequest = new TaskRequestUpdatedDto( + -1L, // Invalid ID + "Task", + "Description", + Status.NEW, + testDuration + ); + + mockMvc.perform(put("/task") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } + + // GET tests + @Test + void getTask_ValidId_ReturnsOk() throws Exception { + TaskResponseDto responseDto = new TaskResponseDto( + 1L, + "Test Task", + "Test Description", + Status.NEW, + testTime, + testTime.plus(testDuration), + testDuration, + TaskType.TASK + ); + + when(taskService.findById(1L)).thenReturn(responseDto); + + mockMvc.perform(get("/task/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)); + } + + @Test + void getTask_InvalidId_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/task/-1")) + .andExpect(status().isBadRequest()); + } + + // GET ALL tests + @Test + void getAllTasks_ReturnsOk() throws Exception { + List tasks = Collections.singletonList( + new TaskResponseDto( + 1L, + "Task", + "Description", + Status.NEW, + testTime, + testTime.plus(testDuration), + testDuration, + TaskType.TASK + ) + ); + + when(taskService.findAll()).thenReturn(tasks); + + mockMvc.perform(get("/task")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1L)); + } + + // DELETE tests + @Test + void deleteTask_ValidId_ReturnsNoContent() throws Exception { + mockMvc.perform(delete("/task/1")) + .andExpect(status().isNoContent()); + + verify(taskService).delete(1L); + } + + @Test + void deleteTask_InvalidId_ReturnsBadRequest() throws Exception { + mockMvc.perform(delete("/task/-1")) + .andExpect(status().isBadRequest()); + } + + // Exception handling tests + @Test + void updateTask_NotFound_ReturnsNotFound() throws Exception { + // Arrange + Long nonExistentTaskId = 999L; + TaskRequestUpdatedDto request = new TaskRequestUpdatedDto( + nonExistentTaskId, + "Non-existent Task", + "Description", + Status.NEW, + testDuration + ); + + String expectedErrorMessage = "Task with ID " + nonExistentTaskId + " not found"; + + when(taskService.update(any(TaskRequestUpdatedDto.class))) + .thenThrow(new NotFoundException(expectedErrorMessage)); + mockMvc.perform(put("/task") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value(expectedErrorMessage)); + + verify(taskService).update(any(TaskRequestUpdatedDto.class)); + } + + @Test + void getTask_NotFound_ReturnsNotFound() throws Exception { + when(taskService.findById(999L)).thenThrow(new NotFoundException("Task not found")); + + mockMvc.perform(get("/task/999")) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/service/src/test/java/service/task/manager/service/HistoryServiceImplTest.java b/service/src/test/java/service/task/manager/service/HistoryServiceImplTest.java new file mode 100644 index 0000000..ad3471b --- /dev/null +++ b/service/src/test/java/service/task/manager/service/HistoryServiceImplTest.java @@ -0,0 +1,91 @@ +package service.task.manager.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.ListOperations; +import org.springframework.data.redis.core.RedisTemplate; +import service.task.manager.model.HistoryEntry; +import service.task.manager.model.enums.TaskType; +import service.task.manager.service.impl.HistoryServiceImpl; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class HistoryServiceImplTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ListOperations listOps; + + @InjectMocks + private HistoryServiceImpl historyService; + + @BeforeEach + void setUp() { + // Ensure redisTemplate.opsForList() returns the mocked listOps + when(redisTemplate.opsForList()).thenReturn(listOps); + } + + @Test + void addToHistory_WithinLimit_AddsEntry() { + // Mock the size of the list and the range result + when(listOps.size("history")).thenReturn(1L); + when(listOps.range("history", 0, -1)).thenReturn(List.of(new HistoryEntry(TaskType.EPIC, 1L))); + + // Call the method under test + historyService.addToHistory(TaskType.EPIC, 1L); + List history = historyService.getHistory(); + + // Verify the result + assertEquals(1, history.size()); + assertEquals(TaskType.EPIC, history.get(0).getType()); + assertEquals(1L, history.get(0).getId()); + + // Verify interactions with listOps + verify(listOps, times(1)).rightPush("history", new HistoryEntry(TaskType.EPIC, 1L)); + } + + @Test + void addToHistory_ExceedsLimit_RemovesOldest() { + // Create a list to simulate the history entries + List historyEntries = new ArrayList<>(); + + // Simulate adding 11 entries + for (long i = 1; i <= 11; i++) { + // Mock the size of the list to increase with each addition + when(listOps.size("history")).thenReturn(i); + historyService.addToHistory(TaskType.TASK, i); + historyEntries.add(new HistoryEntry(TaskType.TASK, i)); + + // If size exceeds MAX_HISTORY_SIZE (10), simulate leftPop + if (i > 10) { + historyEntries.remove(0); // Remove the oldest entry + } + } + + // Mock the final state of the history after trimming + when(listOps.range("history", 0, -1)).thenReturn(historyEntries); + + // Call getHistory to retrieve the result + List history = historyService.getHistory(); + + // Verify the result + assertEquals(10, history.size()); + assertEquals(2L, history.get(0).getId()); // First entry (id=1) should be removed + assertEquals(11L, history.get(9).getId()); + + // Verify interactions with listOps + verify(listOps, times(11)).rightPush(eq("history"), any(HistoryEntry.class)); + verify(listOps, times(1)).leftPop("history"); // Should trim once after exceeding limit + } +} \ No newline at end of file diff --git a/src/task/manager/schedule/model/Epic.java b/src/task/manager/schedule/model/Epic.java deleted file mode 100644 index 12233b1..0000000 --- a/src/task/manager/schedule/model/Epic.java +++ /dev/null @@ -1,52 +0,0 @@ -package task.manager.schedule.model; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -public class Epic extends Task { - - private final List subtaskIds = new ArrayList<>(); - private LocalDateTime endTime; - - public Epic(String name, Status status, String description, LocalDateTime startTime, long duration) { - super(name, status, description, startTime, duration); - } - - public Epic(int id, String name, String description, Status status, LocalDateTime startTime, long duration) { - super(name, status, description, startTime, duration); - setId(id); - } - - public void addSubtaskId(int id) { - subtaskIds.add(id); - } - - public void cleanSubtask() { - subtaskIds.clear(); - } - - public void removeSubtask(int id) { - subtaskIds.remove(id); - } - - public List getSubtaskIds() { - return subtaskIds; - } - - public TaskType getType() { - return TaskType.EPIC; - } - - public void setEndTime(LocalDateTime endTime) { - this.endTime = endTime; - } - - @Override - public String toString() { - return "Epic{" + - "subtaskIds=" + subtaskIds + - ", endTime=" + endTime + - '}'; - } -} diff --git a/src/task/manager/schedule/model/Subtask.java b/src/task/manager/schedule/model/Subtask.java deleted file mode 100644 index 0ff7bf7..0000000 --- a/src/task/manager/schedule/model/Subtask.java +++ /dev/null @@ -1,41 +0,0 @@ -package task.manager.schedule.model; - -import java.time.LocalDateTime; - -public class Subtask extends Task { - - private Integer epicId; - - public Subtask(String name, Status status, String description, LocalDateTime startTime, long duration, int epicId) { - - super(name, status, description, startTime, duration); - setEpicId(epicId); - } - - public Subtask(int id, String name, String description, Status status, LocalDateTime startTime, long duration, Integer epicId) { - super(name, status, description, startTime, duration); - setId(id); - setEpicId(epicId); - } - - @Override - public Integer getEpicId() { - return epicId; - } - - @Override - public TaskType getType() { - return TaskType.SUBTASK; - } - - public void setEpicId(Integer epicId) { - this.epicId = epicId; - } - - @Override - public String toString() { - return "Subtask{" + - "epicId=" + epicId + - '}'; - } -} diff --git a/src/task/manager/schedule/model/Task.java b/src/task/manager/schedule/model/Task.java deleted file mode 100644 index bcc4e8c..0000000 --- a/src/task/manager/schedule/model/Task.java +++ /dev/null @@ -1,118 +0,0 @@ -package task.manager.schedule.model; - -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.Objects; - -public class Task { - private int id; - private String name; - private String description; - private Status status; - - private LocalDateTime startTime; // LocalDateTime - private Duration duration; // минуты или Duration - - public Task(String name, Status status, String description, LocalDateTime startTime, long duration) { - this.name = name; - this.status = status; - this.description = description; - this.startTime = LocalDateTime.from(startTime); - this.duration = Duration.ofMinutes(duration); - } - - public Task(int id, String name, String description, Status status, LocalDateTime startTime, long duration) { - setId(id); - this.name = name; - this.status = status; - this.description = description; - this.startTime = startTime; - this.duration = Duration.ofMinutes(duration); - } - - public int getId() { - return id; - } - - public void setId(int id) { - this.id = id; - } - - public String getName() { - return name; - } - - public Integer getEpicId() { - return null; - } - - public void setName(String name) { - this.name = name; - } - - public String getDescription() { - return description; - } - - public TaskType getType() { - return TaskType.TASK; - } - - public void setDescription(String description) { - this.description = description; - } - - public Status getStatus() { - return status; - } - - public void setStatus(Status status) { - this.status = status; - } - - public LocalDateTime getStartTime() { - return startTime; - } - - public void setStartTime(LocalDateTime startTime) { - this.startTime = startTime; - } - - public long getDuration() { - return duration.toMinutesPart(); - } - - public void setDuration(long duration) { - this.duration = Duration.ofMinutes(duration); - } - - public LocalDateTime getEndTime() { - return startTime.plus(duration); - } - - - @Override - public String toString() { - return "Task{" + - "id=" + id + - ", name='" + name + '\'' + - ", description='" + description + '\'' + - ", status=" + status + - ", startTime=" + startTime + - ", endTime=" + getEndTime() + - '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Task task = (Task) o; - return id == task.id && Objects.equals(name, task.name) && Objects.equals(description, task.description) && status == task.status && Objects.equals(startTime, task.startTime) && Objects.equals(duration, task.duration); - } - - @Override - public int hashCode() { - return Objects.hash(id, name, description, status, startTime, duration); - } -} diff --git a/src/task/manager/schedule/service/FileBackedTaskManager.java b/src/task/manager/schedule/service/FileBackedTaskManager.java index 861022e..eb0a0ad 100644 --- a/src/task/manager/schedule/service/FileBackedTaskManager.java +++ b/src/task/manager/schedule/service/FileBackedTaskManager.java @@ -11,7 +11,6 @@ import task.manager.schedule.exception.ManagerSaveException; import ru.yandex.javacource.golotin.schedule.model.*; import task.manager.schedule.service.inMemory.InMemoryTaskManager; -import task.manager.schedule.model.*; public class FileBackedTaskManager extends InMemoryTaskManager {