diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 00000000..3ae3f77e
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,82 @@
+# Определяем стадии пайплайна
+stages:
+ - test
+ - build
+ - deploy
+
+# Переменные окружения
+variables:
+ DOCKER_REGISTRY: docker.io
+ DOCKER_IMAGE: vladimirchavkin/spring-todo
+ MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
+
+# Кэширование зависимостей Maven для ускорения сборки
+cache:
+ paths:
+ - .m2/repository/
+
+# Сервисы для тестов (PostgreSQL и Redis через Testcontainers)
+services:
+ - name: postgres:16
+ alias: postgres
+ - name: redis:latest
+ alias: redis
+
+# Job для запуска тестов на PR в dev ветку
+test_job:
+ stage: test
+ image: maven:3.8.6-openjdk-17
+ services:
+ - postgres:16
+ - redis:latest
+ variables:
+ SPRING_DATASOURCE_URL: "jdbc:postgresql://postgres:5432/todo_db"
+ SPRING_DATASOURCE_USERNAME: "postgres"
+ SPRING_DATASOURCE_PASSWORD: ""
+ SPRING_REDIS_HOST: "redis"
+ SPRING_REDIS_PORT: "6379"
+ script:
+ - mvn clean test
+ only:
+ refs:
+ - dev
+ except:
+ - master
+
+# Job для проверки сборки приложения на PR в dev
+build_check_job:
+ stage: build
+ image: maven:3.8.6-openjdk-17
+ script:
+ - mvn clean package -DskipTests
+ only:
+ refs:
+ - dev
+ except:
+ - master
+
+# Job для сборки и пуша Docker образа при merge в master
+docker_build_push:
+ stage: deploy
+ image: docker:24.0.5
+ services:
+ - docker:24.0.5-dind
+ variables:
+ DOCKER_TLS_CERTDIR: ""
+ DOCKER_HOST: tcp://docker:2375
+ before_script:
+ - docker login -u $DOCKER_HUB_USERNAME -p $DOCKER_HUB_PASSWORD $DOCKER_REGISTRY
+ script:
+ - mvn clean package -DskipTests
+ - docker build -t $DOCKER_IMAGE:$CI_COMMIT_SHA .
+ - docker push $DOCKER_IMAGE:$CI_COMMIT_SHA
+ - docker tag $DOCKER_IMAGE:$CI_COMMIT_SHA $DOCKER_IMAGE:latest
+ - docker push $DOCKER_IMAGE:latest
+ only:
+ refs:
+ - master
+
+ changes:
+ - Dockerfile
+ - src/**/*
+ - pom.xml
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..c98f1bf7
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,5 @@
+FROM openjdk:21
+WORKDIR /app
+COPY ./target/SpringToDo-0.0.1-SNAPSHOT.jar ./app.jar
+EXPOSE 8088
+CMD ["java","-jar","app.jar"]
\ No newline at end of file
diff --git a/Jenkinsfile b/Jenkinsfile
new file mode 100644
index 00000000..158d77f5
--- /dev/null
+++ b/Jenkinsfile
@@ -0,0 +1,63 @@
+pipeline {
+ agent any
+
+ tools {
+ maven 'maven'
+ jdk 'jdk21'
+ }
+
+ environment {
+ DOCKER_IMAGE = 'vladimirchavkin/spring-todo' // Замените на ваш Docker Hub repo
+ DOCKER_TAG = "${env.BUILD_NUMBER}" // Тег — номер build'а
+ }
+
+ stages {
+ stage('Checkout') {
+ steps {
+ checkout scm // Получаем код из Git
+ }
+ }
+
+ stage('Build') {
+ steps {
+ sh 'mvn clean install -DskipTests' // Проверка сборки без тестов
+ echo 'Сборка прошла успешно!'
+ }
+ }
+
+ stage('Test') { // Опционально: прогон тестов
+ when {
+ expression { return true } // Включите для всех, или only for PRs: branch pattern 'PR-*'
+ }
+ steps {
+ sh 'mvn test' // Если тесты сломаются, pipeline fail
+ echo 'Тесты прошли успешно!'
+ }
+ }
+
+ stage('Build and Push Docker') {
+ when {
+ branch 'master' // Только для master
+ }
+ steps {
+ script {
+ docker.build("${DOCKER_IMAGE}:${DOCKER_TAG}", ".") // Собираем image
+ withDockerRegistry([credentialsId: 'docker-hub-credentials', url: 'https://index.docker.io/v1/']) {
+ dockerImage.push() // Пушим в Docker Hub
+ dockerImage.push('latest') // Опционально: тег latest
+ }
+ }
+ echo 'Docker image собран и загружен в Docker Hub!'
+ }
+ }
+ }
+
+ post {
+ always {
+ echo 'Pipeline завершен.'
+ }
+ failure {
+ echo 'Pipeline провалился! Проверьте логи.'
+ }
+ }
+}
\ No newline at end of file
diff --git a/docker/.env b/docker/.env
new file mode 100644
index 00000000..b375a8c3
--- /dev/null
+++ b/docker/.env
@@ -0,0 +1,5 @@
+POSTGRES_VERSION=15
+POSTGRES_DB=spring-todo
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=postgres
+REDIS_VERSION=latest
\ No newline at end of file
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
new file mode 100644
index 00000000..b4fb387d
--- /dev/null
+++ b/docker/docker-compose.yml
@@ -0,0 +1,71 @@
+version: '3.8'
+
+services:
+ app:
+ build:
+ context: ../
+ dockerfile: Dockerfile
+ ports:
+ - "8088:8088"
+ environment:
+ - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/springtodo
+ - SPRING_DATASOURCE_USERNAME=postgres
+ - SPRING_DATASOURCE_PASSWORD=postgres
+ - SPRING_FLYWAY_URL=jdbc:postgresql://db:5432/springtodo
+ - SPRING_FLYWAY_USER=postgres
+ - SPRING_FLYWAY_PASSWORD=postgres
+ - SPRING_DATA_REDIS_HOST=redis
+ - SPRING_DATA_REDIS_PORT=6379
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8088/actuator/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+ networks:
+ - default
+
+ db:
+ image: postgres:16
+ environment:
+ - POSTGRES_DB=springtodo
+ - POSTGRES_USER=postgres
+ - POSTGRES_PASSWORD=postgres
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - default
+
+ redis:
+ image: redis:7
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - default
+
+networks:
+ default:
+ driver: bridge
+
+volumes:
+ postgres_data:
+ redis_data:
\ No newline at end of file
diff --git a/docker/postgres/sql/postgres-init.sql b/docker/postgres/sql/postgres-init.sql
new file mode 100644
index 00000000..7192b55e
--- /dev/null
+++ b/docker/postgres/sql/postgres-init.sql
@@ -0,0 +1 @@
+CREATE DATABASE springtodo;
\ No newline at end of file
diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml
new file mode 100644
index 00000000..31dbe8d1
--- /dev/null
+++ b/k8s/deployment.yaml
@@ -0,0 +1,30 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: springtodo-k8s-deployment
+ labels:
+ app: todo-k8s
+spec:
+ replicas: 2
+ selector:
+ matchLabels:
+ app: todo-k8s
+ template:
+ metadata:
+ labels:
+ app: todo-k8s
+ spec:
+ containers:
+ - name: todo-k8s-container
+ image: todo-k8s:latest
+ ports:
+ - containerPort: 8088
+ resources:
+ requests:
+ memory: "2000Mi"
+ cpu: "1000m"
+ limits:
+ memory: "3000Mi"
+ cpu: "2000m"
+---
+# В одном файле можно описать несколько ресурсов
\ No newline at end of file
diff --git a/k8s/service.yaml b/k8s/service.yaml
new file mode 100644
index 00000000..d72cd2be
--- /dev/null
+++ b/k8s/service.yaml
@@ -0,0 +1,11 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: springtodo-k8s-service
+spec:
+ type: LoadBalancer
+ ports:
+ - port: 80 # Порт сервиса
+ targetPort: 8088 # Порт пода
+ selector:
+ app: todo-k8s # Селектор: находит поды по метке
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 03bc446d..073dc2f6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,8 +5,8 @@
org.springframework.boot
spring-boot-starter-parent
- 3.3.5
-
+ 3.5.3
+
com.emobile
SpringToDo
@@ -16,19 +16,139 @@
21
+ 2.8.9
+ 1.18.38
+ 1.6.3
+
org.springframework.boot
spring-boot-starter
-
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
org.springframework.boot
spring-boot-starter-test
test
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jdbc
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.7
+
+
+
+ org.sonarsource.scanner.maven
+ sonar-maven-plugin
+ 3.10.0.2594
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+ redis.clients
+ jedis
+ 5.1.2
+
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+ provided
+
+
+
+
+ org.mapstruct
+ mapstruct
+ ${mapstruct.version}
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+ provided
+
+
+
+
+ org.postgresql
+ postgresql
+ 42.7.7
+
+
+
+
+ org.flywaydb
+ flyway-database-postgresql
+ 11.10.4
+ runtime
+
+
+
+
+ org.springframework.restdocs
+ spring-restdocs-mockmvc
+ test
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ ${springdoc.version}
+
+
+
+
+ org.springframework.boot
+ spring-boot-testcontainers
+ test
+
+
+ org.testcontainers
+ postgresql
+ 1.20.0
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ 1.20.0
+ test
+
+
+ org.skyscreamer
+ jsonassert
+ 1.5.1
+ test
+
@@ -37,7 +157,27 @@
org.springframework.boot
spring-boot-maven-plugin
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ ${java.version}
+ ${java.version}
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+
+
+
+
-
-
+
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java
index 41980514..20dd6a94 100644
--- a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java
+++ b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java
@@ -2,8 +2,12 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories;
@SpringBootApplication
+@EnableCaching
+@EnableJdbcRepositories
public class SpringToDoApplication {
public static void main(String[] args) {
diff --git a/src/main/java/com/emobile/springtodo/configuration/cache/RedisConfig.java b/src/main/java/com/emobile/springtodo/configuration/cache/RedisConfig.java
new file mode 100644
index 00000000..1cd627b9
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/configuration/cache/RedisConfig.java
@@ -0,0 +1,22 @@
+package com.emobile.springtodo.configuration.cache;
+
+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.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+@Configuration
+public class RedisConfig {
+
+ @Bean
+ public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
+ RedisTemplate template = new RedisTemplate<>();
+ template.setConnectionFactory(redisConnectionFactory);
+ template.setKeySerializer(new StringRedisSerializer());
+ template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
+ return template;
+ }
+
+}
diff --git a/src/main/java/com/emobile/springtodo/configuration/openapi/OpenApiConfiguration.java b/src/main/java/com/emobile/springtodo/configuration/openapi/OpenApiConfiguration.java
new file mode 100644
index 00000000..5403717a
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/configuration/openapi/OpenApiConfiguration.java
@@ -0,0 +1,104 @@
+package com.emobile.springtodo.configuration.openapi;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Contact;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.info.License;
+import io.swagger.v3.oas.models.servers.Server;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.List;
+
+/**
+ * Конфигурационный класс для настройки OpenAPI (Swagger).
+ * Создаёт bean {@link OpenAPI} с информацией о API и списком серверов на основе
+ * свойств, заданных в {@link OpenApiConfigurationProperties}.
+ */
+@Configuration
+@RequiredArgsConstructor
+@EnableConfigurationProperties
+public class OpenApiConfiguration {
+
+ /**
+ * Свойства конфигурации OpenAPI, содержащие информацию о заголовке, версии, описании и серверах.
+ */
+ private final OpenApiConfigurationProperties openApiConfigurationProperties;
+
+ /**
+ * Создаёт bean {@link OpenAPI} для настройки документации API.
+ *
+ * @return объект {@link OpenAPI} с информацией и списком серверов
+ */
+ @Bean
+ public OpenAPI openApi() {
+ return new OpenAPI()
+ .info(getInfoForOpenApi())
+ .servers(getServersForOpenApi());
+ }
+
+ /**
+ * Формирует информацию об API для OpenAPI.
+ * Включает заголовок, версию, описание, контактные данные, лицензию и условия использования.
+ *
+ * @return объект {@link Info} с метаданными API
+ */
+ public Info getInfoForOpenApi() {
+ final OpenApiConfigurationProperties.InfoConfig infoConfig = openApiConfigurationProperties.getInfo();
+ return new Info()
+ .title(infoConfig.getTitle())
+ .version(infoConfig.getVersion())
+ .contact(getContactForOpenApi())
+ .description(infoConfig.getDescription())
+ .license(getLicenseForOpenApi())
+ .termsOfService("https://example.com/terms");
+ }
+
+ /**
+ * Формирует контактные данные для OpenAPI.
+ *
+ * @return объект {@link Contact} с именем, email и URL
+ */
+ private Contact getContactForOpenApi() {
+ return new Contact()
+ .name("Vladimir Chavkin")
+ .email("vladimirchavkinwork@gmail.com")
+ .url("https://github.com/vladimirchavkin");
+ }
+
+ /**
+ * Формирует лицензию для OpenAPI.
+ *
+ * @return объект {@link License} с названием и URL лицензии
+ */
+ private License getLicenseForOpenApi() {
+ return new License()
+ .name("MIT License")
+ .url("https://choosealicense.com/licenses/mit/");
+ }
+
+ /**
+ * Формирует список серверов для OpenAPI на основе конфигурации.
+ *
+ * @return список объектов {@link Server}, представляющих серверы API
+ */
+ private List getServersForOpenApi() {
+ return openApiConfigurationProperties.getServers().stream()
+ .map(this::getServer)
+ .toList();
+ }
+
+ /**
+ * Преобразует конфигурацию сервера в объект {@link Server} для OpenAPI.
+ *
+ * @param serverConfig конфигурация сервера из {@link OpenApiConfigurationProperties.ServerConfig}
+ * @return объект {@link Server} с URL и описанием
+ */
+ private Server getServer(final OpenApiConfigurationProperties.ServerConfig serverConfig) {
+ return new Server()
+ .url(serverConfig.getUrl())
+ .description(serverConfig.getDescription());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/configuration/openapi/OpenApiConfigurationProperties.java b/src/main/java/com/emobile/springtodo/configuration/openapi/OpenApiConfigurationProperties.java
new file mode 100644
index 00000000..810df02b
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/configuration/openapi/OpenApiConfigurationProperties.java
@@ -0,0 +1,72 @@
+package com.emobile.springtodo.configuration.openapi;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.List;
+
+/**
+ * Конфигурационный класс для настройки свойств OpenAPI (Swagger).
+ * Содержит параметры для описания API (информация, серверы) и связывается с
+ * префиксом конфигурации {@value #PREFIX} в файле настроек приложения.
+ */
+@Data
+@Configuration
+@ConfigurationProperties(OpenApiConfigurationProperties.PREFIX)
+public class OpenApiConfigurationProperties {
+
+ /**
+ * Префикс для свойств конфигурации OpenAPI в файле настроек (например, application.yml).
+ */
+ public static final String PREFIX = "springdoc";
+
+ /**
+ * Конфигурация информации об API, включая заголовок, версию и описание.
+ */
+ private InfoConfig info;
+
+ /**
+ * Список конфигураций серверов, доступных для API.
+ */
+ private List servers;
+
+ /**
+ * Внутренний класс для хранения информации об API.
+ * Содержит заголовок, версию и описание API для отображения в документации OpenAPI.
+ */
+ @Data
+ public static final class InfoConfig {
+ /**
+ * Заголовок API, отображаемый в документации OpenAPI.
+ */
+ private String title;
+
+ /**
+ * Версия API, указанная в документации OpenAPI.
+ */
+ private String version;
+
+ /**
+ * Описание API, отображаемое в документации OpenAPI.
+ */
+ private String description;
+ }
+
+ /**
+ * Внутренний класс для хранения конфигурации серверов API.
+ * Содержит URL и описание сервера для отображения в документации OpenAPI.
+ */
+ @Data
+ public static final class ServerConfig {
+ /**
+ * URL сервера, используемого для доступа к API.
+ */
+ private String url;
+
+ /**
+ * Описание сервера, отображаемое в документации OpenAPI.
+ */
+ private String description;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/controller/SwaggerTaskController.java b/src/main/java/com/emobile/springtodo/controller/SwaggerTaskController.java
new file mode 100644
index 00000000..22a9b3b5
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/controller/SwaggerTaskController.java
@@ -0,0 +1,230 @@
+package com.emobile.springtodo.controller;
+
+import com.emobile.springtodo.controller.response.CustomResponse;
+import com.emobile.springtodo.entity.dto.TaskRequest;
+import com.emobile.springtodo.entity.dto.TaskResponse;
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.PositiveOrZero;
+import org.springframework.data.domain.Page;
+
+@Tag(name = "Task Controller", description = "Контроллер для работы с задачами.")
+public interface SwaggerTaskController {
+
+ @Operation(
+ summary = "Создать задачу",
+ description = "Создать задачу по заданному DTO",
+ responses = {
+ @ApiResponse(
+ description = "Задача создана",
+ responseCode = "200",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = TaskResponse.class),
+ examples = {
+ @ExampleObject(
+ value = """
+ "id": 1,
+ "title": "Сходить в магазин",
+ "description": "Купить масло, хлеб, яйца. И мороженое",
+ "isCompleted": "NOT_COMPLETED"
+ "createdAt": "2023-01-01T00:00:00",
+ "updatedAt": "2023-02-01T00:00:00"
+
+ """
+ )
+ }
+ )
+ )
+ }
+ )
+ CustomResponse create(@Valid TaskRequest taskRequest);
+
+ @Operation(
+ summary = "Найти задачу по id",
+ description = "Найти задачу по заданному id",
+ responses = {
+ @ApiResponse(
+ description = "Задача найдена",
+ responseCode = "200",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = TaskResponse.class),
+ examples = {
+ @ExampleObject(
+ value = """
+ "id": 1,
+ "title": "Сходить в магазин",
+ "description": "Купить масло, хлеб, яйца. И мороженое",
+ "isCompleted": "NOT_COMPLETED"
+ "createdAt": "2023-01-01T00:00:00",
+ "updatedAt": "2023-02-01T00:00:00"
+ """
+ )
+ }
+ )
+ ),
+ @ApiResponse(
+ description = "Задача не найдена",
+ responseCode = "404",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = TaskResponse.class)
+ )
+ )
+ }
+ )
+ CustomResponse findById(@Positive Long id);
+
+ @Operation(
+ summary = "Найти все задачи",
+ description = "Найти все задачи по заданным параметрам",
+ responses = {
+ @ApiResponse(
+ description = "Задачи найдены",
+ responseCode = "200",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = TaskResponse.class),
+ examples = {
+ @ExampleObject(
+ value = """
+ tasks: [
+ {
+ "id": 1,
+ "title": "Сходить в магазин",
+ "description": "Купить масло, хлеб, яйца. И мороженое",
+ "isCompleted": "NOT_COMPLETED"
+ "createdAt": "2023-02-01T12:00:00",
+ "updatedAt": "2023-02-01T12:00:00"
+ },
+ {
+ "id": 2,
+ "title": "Убраться дома",
+ "description": "Пропылесосить и помыть пол.",
+ "isCompleted": "NOT_COMPLETED"
+ "createdAt": "2023-01-01T15:00:00",
+ "updatedAt": "2023-02-01T15:00:00"
+ }
+ ]
+ """
+ )
+ }
+ )
+ )
+ }
+ )
+ CustomResponse> findAll(@PositiveOrZero Integer page, @Positive Integer offset);
+
+ @Operation(
+ summary = "Найти все задачи по isCompleted",
+ description = "Найти все задачи по заданным параметрам",
+ responses = {
+ @ApiResponse(
+ description = "Задачи найдены",
+ responseCode = "200",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = TaskResponse.class),
+ examples = {
+ @ExampleObject(
+ value = """
+ "id": 1,
+ "title": "Сходить в магазин",
+ "description": "Купить масло, хлеб, яйца. И мороженое",
+ "isCompleted": "COMPLETED"
+ "createdAt": "2023-02-01T12:00:00",
+ "updatedAt": "2023-02-01T12:00:00"
+ """
+ )
+ }
+ )
+ )
+ }
+ )
+ CustomResponse> findAllByIsCompleted(
+ CompletionStatus isCompleted,
+ @PositiveOrZero Integer page,
+ @Positive Integer offset
+ );
+
+ @Operation(
+ summary = "Обновить задачу",
+ description = "Обновить задачу по заданному id",
+ responses = {
+ @ApiResponse(
+ description = "Задача обновлена",
+ responseCode = "200",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = TaskResponse.class),
+ examples = {
+ @ExampleObject(
+ value = """
+ "id": 1,
+ "title": "Сходить в магазин",
+ "description": "Купить масло, хлеб, яйца. И мороженое - 2шт",
+ "isCompleted": "NOT_COMPLETED"
+ "createdAt": "2023-02-01T12:00:00",
+ "updatedAt": "2023-02-01T13:00:00"
+ """
+ )
+ }
+ )
+ ),
+ @ApiResponse(
+ description = "Задача не найдена",
+ responseCode = "404",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = TaskResponse.class)
+ )
+ )
+ }
+ )
+ CustomResponse update(@Valid TaskRequest taskRequest, @Positive Long id);
+
+ @Operation(
+ summary = "Удалить задачу",
+ description = "Удалить задачу по заданному id",
+ responses = {
+ @ApiResponse(
+ description = "Задача удалена",
+ responseCode = "200",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = TaskResponse.class),
+ examples = {
+ @ExampleObject(
+ value = """
+ "id": 1,
+ "title": "Сходить в магазин",
+ "description": "Купить масло, хлеб, яйца. И мороженое - 2шт",
+ "isCompleted": "NOT_COMPLETED"
+ "createdAt": "2023-02-01T12:00:00",
+ "updatedAt": "2023-02-01T13:00:00"
+ """
+ )
+ }
+ )
+ ),
+ @ApiResponse(
+ description = "Задача не найдена",
+ responseCode = "404",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = TaskResponse.class)
+ )
+ )
+ }
+ )
+ CustomResponse delete(@Positive Long id);
+
+}
diff --git a/src/main/java/com/emobile/springtodo/controller/TaskController.java b/src/main/java/com/emobile/springtodo/controller/TaskController.java
new file mode 100644
index 00000000..6822e61f
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/controller/TaskController.java
@@ -0,0 +1,86 @@
+package com.emobile.springtodo.controller;
+
+import com.emobile.springtodo.controller.response.CustomResponse;
+import com.emobile.springtodo.entity.dto.TaskRequest;
+import com.emobile.springtodo.entity.dto.TaskResponse;
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import com.emobile.springtodo.service.TaskService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.web.bind.annotation.*;
+
+
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/tasks")
+@RequiredArgsConstructor
+public class TaskController implements SwaggerTaskController{
+
+ private final TaskService taskService;
+
+ @PostMapping
+ public CustomResponse create(
+ @RequestBody final TaskRequest taskRequest
+ ) {
+ log.info("Request to controller for create Task with DTO: {}", taskRequest);
+ TaskResponse taskResponse = taskService.create(taskRequest);
+ log.info("Response from controller for create Task with DTO: {}", taskResponse);
+ return CustomResponse.success(taskResponse);
+ }
+
+ @GetMapping("/{id}")
+ public CustomResponse findById(
+ @PathVariable Long id
+ ) {
+ log.info("Request to find Task by id: {}", id);
+ TaskResponse taskResponse = taskService.findById(id);
+ log.info("Task found with id: {}", taskResponse.id());
+ return CustomResponse.success(taskResponse);
+ }
+
+ @GetMapping("/all")
+ public CustomResponse> findAll(
+ @RequestParam(defaultValue = "0") Integer page,
+ @RequestParam(defaultValue = "10") Integer offset
+ ) {
+ log.info("Request to find all Tasks with page: {}, size: {}", page, offset);
+ Page taskResponse = taskService.findAll(page, offset);
+ log.info("Found {} tasks", taskResponse.getTotalElements());
+ return CustomResponse.success(taskResponse);
+ }
+
+ @GetMapping("/by-is-completed")
+ public CustomResponse> findAllByIsCompleted(
+ @RequestParam CompletionStatus isCompleted,
+ @RequestParam(defaultValue = "0") Integer page,
+ @RequestParam(defaultValue = "10") Integer offset
+ ) {
+ log.info("Request to find Tasks by isCompleted: {}", isCompleted);
+ Page taskResponse = taskService.findAllByIsCompleted(isCompleted, page, offset);
+ log.info("Found {} tasks by isCompleted", taskResponse.getTotalElements());
+ return CustomResponse.success(taskResponse);
+ }
+
+ @PutMapping("/{id}")
+ public CustomResponse update(
+ @RequestBody TaskRequest taskRequest,
+ @PathVariable Long id
+ ) {
+ log.info("Request to update Task by id: {}", id);
+ TaskResponse taskResponse = taskService.update(taskRequest, id);
+ log.info("Task updated with id: {}", taskResponse.id());
+ return CustomResponse.success(taskResponse);
+ }
+
+ @DeleteMapping("/{id}")
+ public CustomResponse delete(
+ @PathVariable Long id
+ ) {
+ log.info("Request to delete Task by id: {}", id);
+ TaskResponse taskResponse = taskService.delete(id);
+ log.info("Task deleted with id: {}", taskResponse.id());
+ return CustomResponse.success(taskResponse);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/controller/response/CustomResponse.java b/src/main/java/com/emobile/springtodo/controller/response/CustomResponse.java
new file mode 100644
index 00000000..f9c304ed
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/controller/response/CustomResponse.java
@@ -0,0 +1,24 @@
+package com.emobile.springtodo.controller.response;
+
+import org.springframework.http.HttpStatus;
+
+public record CustomResponse(
+ T data,
+ String errorCode,
+ String errorMessage,
+ HttpStatus status
+ ) {
+ public static CustomResponse success(
+ final T data
+ ) {
+ return new CustomResponse<>(data, null, null, HttpStatus.OK);
+ }
+
+ public static CustomResponse error(
+ final String errorMessage,
+ final String errorCode,
+ final HttpStatus status
+ ) {
+ return new CustomResponse<>(null, errorMessage, errorCode, status);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/entity/Task.java b/src/main/java/com/emobile/springtodo/entity/Task.java
new file mode 100644
index 00000000..0ca03aaf
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/entity/Task.java
@@ -0,0 +1,37 @@
+package com.emobile.springtodo.entity;
+
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Setter
+@AllArgsConstructor
+@NoArgsConstructor
+@Entity
+@Table(name = "tasks")
+public class Task {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false, length = 50)
+ private String title;
+
+ @Column(nullable = false, length = 500)
+ private String description;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "is_completed", nullable = false)
+ private CompletionStatus isCompleted;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt = LocalDateTime.now();
+
+ @Column(name = "updated_at")
+ private LocalDateTime updatedAt;
+
+}
diff --git a/src/main/java/com/emobile/springtodo/entity/dto/TaskRequest.java b/src/main/java/com/emobile/springtodo/entity/dto/TaskRequest.java
new file mode 100644
index 00000000..d229c2c5
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/entity/dto/TaskRequest.java
@@ -0,0 +1,28 @@
+package com.emobile.springtodo.entity.dto;
+
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import jakarta.validation.constraints.*;
+import org.springframework.validation.annotation.Validated;
+
+import java.time.LocalDateTime;
+
+@Validated
+public record TaskRequest(
+
+ @NotBlank(message = "'title' must not be null or empty.")
+ @Size(min = 1, max = 50, message = "'title' should be between 1 to 50.")
+ String title,
+
+ @NotBlank(message = "'description' must not be null or empty.")
+ @Size(min = 1, max = 500, message = "'description' should be between 1 to 500.")
+ String description,
+
+ @NotNull(message = "'isCompleted' must not be null.")
+ CompletionStatus isCompleted,
+
+ LocalDateTime createdAt,
+
+ LocalDateTime updatedAt
+
+) {
+}
diff --git a/src/main/java/com/emobile/springtodo/entity/dto/TaskResponse.java b/src/main/java/com/emobile/springtodo/entity/dto/TaskResponse.java
new file mode 100644
index 00000000..6a2366b6
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/entity/dto/TaskResponse.java
@@ -0,0 +1,23 @@
+package com.emobile.springtodo.entity.dto;
+
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+public record TaskResponse(
+
+ Long id,
+
+ String title,
+
+ String description,
+
+ CompletionStatus isCompleted,
+
+ LocalDateTime createdAt,
+
+ LocalDateTime updatedAt
+
+) implements Serializable {
+}
diff --git a/src/main/java/com/emobile/springtodo/entity/enumeration/CompletionStatus.java b/src/main/java/com/emobile/springtodo/entity/enumeration/CompletionStatus.java
new file mode 100644
index 00000000..602d3ad5
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/entity/enumeration/CompletionStatus.java
@@ -0,0 +1,17 @@
+package com.emobile.springtodo.entity.enumeration;
+
+import lombok.Getter;
+
+@Getter
+public enum CompletionStatus {
+
+ COMPLETED("COMPLETED"),
+ NOT_COMPLETED("NOT_COMPLETED");
+
+ private final String status;
+
+ CompletionStatus(String status) {
+ this.status = status;
+ }
+
+}
diff --git a/src/main/java/com/emobile/springtodo/entity/enumeration/ExceptionMessage.java b/src/main/java/com/emobile/springtodo/entity/enumeration/ExceptionMessage.java
new file mode 100644
index 00000000..11d56478
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/entity/enumeration/ExceptionMessage.java
@@ -0,0 +1,37 @@
+package com.emobile.springtodo.entity.enumeration;
+
+import lombok.Getter;
+
+import java.util.IllegalFormatException;
+
+@Getter
+public enum ExceptionMessage {
+
+ ENTITY_NOT_FOUND_BY_ID("Entity not found by id: %s", "404"),
+ ENTITY_IS_NULL("Entity is null", "404"),
+
+ ID_IS_NULL("Task id is null", "400"),
+ PAGEABLE_IS_NULL("Pageable is null", "400"),
+ TITLE_IS_NULL_OR_EMPTY("Task title is null or empty: %s", "400"),
+ DESCRIPTION_IS_NULL_OR_EMPTY("Task description is null or empty: %s", "400"),
+ CREATED_AT_IS_NULL("Task createdAt is null", "400"),
+ COMPLETED_IS_NULL("Task isCompleted is null", "400"),
+
+ UNIQUE_VIOLATION("Unique violation: %s", "409");
+
+ private final String message;
+ private final String errorCode;
+
+ ExceptionMessage(final String message, final String errorCode) {
+ this.message = message;
+ this.errorCode = errorCode;
+ }
+
+ public String getMessage(Object... params) {
+ try {
+ return String.format(message, params);
+ } catch (IllegalFormatException e) {
+ return message;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/entity/enumeration/SqlQuery.java b/src/main/java/com/emobile/springtodo/entity/enumeration/SqlQuery.java
new file mode 100644
index 00000000..2117f15e
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/entity/enumeration/SqlQuery.java
@@ -0,0 +1,25 @@
+package com.emobile.springtodo.entity.enumeration;
+
+import lombok.Getter;
+
+@Getter
+public enum SqlQuery {
+ SAVE("INSERT INTO tasks (title, description, is_completed, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"),
+ UPDATE("UPDATE tasks SET title = ?, description = ?, is_completed = ?, updated_at = ? WHERE id = ?"),
+ FIND_BY_ID("SELECT id, title, description, is_completed, created_at, updated_at FROM tasks WHERE id = ?"),
+ FIND_ALL("SELECT id, title, description, is_completed, created_at, updated_at FROM tasks"),
+ FIND_ALL_BY_COMPLETED("SELECT id, title, description, is_completed, created_at, updated_at FROM tasks WHERE is_completed = ?"),
+ DELETE("DELETE FROM tasks WHERE id = ?"),
+ COUNT("SELECT COUNT(*) FROM tasks"),
+ COUNT_BY_COMPLETED("SELECT COUNT(*) FROM tasks WHERE is_completed = ?");
+
+ private final String query;
+
+ SqlQuery(String query) {
+ this.query = query;
+ }
+
+ public String getQuery() {
+ return query;
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/exception/ApplicationException.java b/src/main/java/com/emobile/springtodo/exception/ApplicationException.java
new file mode 100644
index 00000000..47883123
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/ApplicationException.java
@@ -0,0 +1,14 @@
+package com.emobile.springtodo.exception;
+
+import lombok.Getter;
+
+@Getter
+public class ApplicationException extends RuntimeException {
+
+ private final String errorCode;
+
+ public ApplicationException(final String message, final String errorCode) {
+ super(message);
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/exception/EntityIsNullException.java b/src/main/java/com/emobile/springtodo/exception/EntityIsNullException.java
new file mode 100644
index 00000000..fdac00d3
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/EntityIsNullException.java
@@ -0,0 +1,7 @@
+package com.emobile.springtodo.exception;
+
+public class EntityIsNullException extends ApplicationException {
+ public EntityIsNullException(final String message, final String errorCode) {
+ super(message, errorCode);
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/exception/PageableIllegalArgumentException.java b/src/main/java/com/emobile/springtodo/exception/PageableIllegalArgumentException.java
new file mode 100644
index 00000000..4c038613
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/PageableIllegalArgumentException.java
@@ -0,0 +1,8 @@
+package com.emobile.springtodo.exception;
+
+public class PageableIllegalArgumentException extends ApplicationException {
+
+ public PageableIllegalArgumentException(final String message, final String errorCode) {
+ super(message, errorCode);
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/exception/TaskInvalidFieldException.java b/src/main/java/com/emobile/springtodo/exception/TaskInvalidFieldException.java
new file mode 100644
index 00000000..461ed1f7
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/TaskInvalidFieldException.java
@@ -0,0 +1,8 @@
+package com.emobile.springtodo.exception;
+
+public class TaskInvalidFieldException extends ApplicationException {
+
+ public TaskInvalidFieldException(final String message, final String errorCode) {
+ super(message, errorCode);
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/exception/TaskNotFoundException.java b/src/main/java/com/emobile/springtodo/exception/TaskNotFoundException.java
new file mode 100644
index 00000000..5ffc74f0
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/TaskNotFoundException.java
@@ -0,0 +1,8 @@
+package com.emobile.springtodo.exception;
+
+public class TaskNotFoundException extends ApplicationException {
+
+ public TaskNotFoundException(final String message, final String errorCode) {
+ super(message, errorCode);
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/exception/UniqueViolationException.java b/src/main/java/com/emobile/springtodo/exception/UniqueViolationException.java
new file mode 100644
index 00000000..855243c7
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/UniqueViolationException.java
@@ -0,0 +1,9 @@
+package com.emobile.springtodo.exception;
+
+public class UniqueViolationException extends ApplicationException {
+
+ public UniqueViolationException(final String message, final String errorCode) {
+ super(message, errorCode);
+ }
+
+}
diff --git a/src/main/java/com/emobile/springtodo/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/emobile/springtodo/exception/handler/GlobalExceptionHandler.java
new file mode 100644
index 00000000..5c30d44d
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/handler/GlobalExceptionHandler.java
@@ -0,0 +1,34 @@
+package com.emobile.springtodo.exception.handler;
+
+import com.emobile.springtodo.controller.response.CustomResponse;
+import com.emobile.springtodo.exception.PageableIllegalArgumentException;
+import com.emobile.springtodo.exception.TaskInvalidFieldException;
+import com.emobile.springtodo.exception.TaskNotFoundException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+
+@Slf4j
+@ControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler
+ public CustomResponse handleTaskInvalidFieldException(final TaskInvalidFieldException e) {
+ log.error(e.getMessage());
+ return CustomResponse.error(e.getMessage(), e.getErrorCode(), HttpStatus.BAD_REQUEST);
+ }
+
+ @ExceptionHandler
+ public CustomResponse handleTaskNotFoundException(final TaskNotFoundException e) {
+ log.error(e.getMessage());
+ return CustomResponse.error(e.getMessage(), e.getErrorCode(), HttpStatus.NOT_FOUND);
+ }
+
+ @ExceptionHandler
+ public CustomResponse handlePageableIllegalArgumentException(final PageableIllegalArgumentException e) {
+ log.error(e.getMessage());
+ return CustomResponse.error(e.getMessage(), e.getErrorCode(), HttpStatus.BAD_REQUEST);
+ }
+
+}
diff --git a/src/main/java/com/emobile/springtodo/mapper/TaskMapper.java b/src/main/java/com/emobile/springtodo/mapper/TaskMapper.java
new file mode 100644
index 00000000..de39f9ed
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/mapper/TaskMapper.java
@@ -0,0 +1,45 @@
+package com.emobile.springtodo.mapper;
+
+import com.emobile.springtodo.entity.Task;
+import com.emobile.springtodo.entity.dto.TaskRequest;
+import com.emobile.springtodo.entity.dto.TaskResponse;
+import org.mapstruct.*;
+
+/**
+ * Интерфейс для маппинга между сущностью {@link Task} и DTO ({@link TaskRequest}, {@link TaskResponse}).
+ * Используется для преобразования данных между слоями приложения.
+ */
+@Mapper(componentModel = "spring")
+public interface TaskMapper {
+
+ /**
+ * Преобразует сущность {@link Task} в DTO {@link TaskResponse} для ответа клиенту.
+ *
+ * @param task сущность задачи
+ * @return DTO с данными задачи
+ */
+ TaskResponse fromEntityToResponse(Task task);
+
+ /**
+ * Преобразует DTO {@link TaskRequest} в сущность {@link Task} для сохранения в базе данных.
+ * Поля {@code id} и {@code createdAt} игнорируются, так как устанавливаются на уровне сервиса или базы данных.
+ *
+ * @param taskRequest DTO с данными запроса
+ * @return сущность задачи
+ */
+ @Mapping(target = "createdAt", ignore = true)
+ Task fromRequestToEntity(TaskRequest taskRequest);
+
+ /**
+ * Обновляет существующую сущность {@link Task} на основе данных из {@link TaskRequest}.
+ * Поля {@code id} и {@code createdAt} игнорируются, чтобы сохранить идентификатор и дату уже существующей задачи.
+ *
+ * @param task сущность задачи, подлежащая обновлению
+ * @param taskRequest DTO с новыми данными
+ * @return обновлённая сущность задачи
+ */
+
+ @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
+ @Mapping(target = "updatedAt", expression = "java(java.time.LocalDateTime.now())")
+ Task updateEntityFromRequest(@MappingTarget Task task, TaskRequest taskRequest);
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/repository/HibernateTaskRepository.java b/src/main/java/com/emobile/springtodo/repository/HibernateTaskRepository.java
new file mode 100644
index 00000000..48b37b62
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/repository/HibernateTaskRepository.java
@@ -0,0 +1,180 @@
+package com.emobile.springtodo.repository;
+
+import com.emobile.springtodo.entity.Task;
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import com.emobile.springtodo.entity.enumeration.ExceptionMessage;
+import com.emobile.springtodo.exception.EntityIsNullException;
+import com.emobile.springtodo.exception.PageableIllegalArgumentException;
+import com.emobile.springtodo.exception.TaskInvalidFieldException;
+import com.emobile.springtodo.exception.TaskNotFoundException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.hibernate.Session;
+import org.hibernate.SessionFactory;
+import org.hibernate.query.Query;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+import static com.emobile.springtodo.validator.TaskRepositoryValidator.validateTask;
+
+@Slf4j
+@Repository
+@RequiredArgsConstructor
+public class HibernateTaskRepository implements TaskRepository {
+
+ private final SessionFactory sessionFactory;
+
+ @Override
+ public Task save(Task task) {
+ validateTask(task);
+
+ try (Session session = sessionFactory.openSession()) {
+ session.beginTransaction();
+ if (task.getId() == null) {
+ session.persist(task);
+ } else {
+ session.merge(task);
+ }
+ session.getTransaction().commit();
+ return task;
+ } catch (Exception e) {
+ log.error("Error while saving task {}", task, e);
+ throw e;
+ }
+ }
+
+ @Override
+ public Optional findById(Long id) {
+ if (id == null) {
+ throw new TaskInvalidFieldException(
+ ExceptionMessage.ID_IS_NULL.getMessage(),
+ ExceptionMessage.ID_IS_NULL.getErrorCode()
+ );
+ }
+
+ try (Session session = sessionFactory.openSession()) {
+ Task task = session.get(Task.class, id);
+ return Optional.ofNullable(task);
+ }
+ }
+
+ @Override
+ public Page findAll(Pageable pageable) {
+ if (pageable == null) {
+ throw new PageableIllegalArgumentException(
+ ExceptionMessage.PAGEABLE_IS_NULL.getMessage(),
+ ExceptionMessage.PAGEABLE_IS_NULL.getErrorCode()
+ );
+ }
+
+ try (Session session = sessionFactory.openSession()) {
+ Query query = session.createQuery("FROM Task ORDER BY id", Task.class);
+ applyPagination(query, pageable);
+ List tasks = query.list();
+
+ Long total = session.createQuery("SELECT COUNT(t) FROM Task t", Long.class).uniqueResult();
+
+ return new PageImpl<>(tasks, pageable, total != null ? total : 0);
+ }
+ }
+
+ @Override
+ public Page findAllByIsCompleted(CompletionStatus isCompleted, Pageable pageable) {
+ if (isCompleted == null) {
+ throw new TaskInvalidFieldException(
+ ExceptionMessage.COMPLETED_IS_NULL.getMessage(),
+ ExceptionMessage.COMPLETED_IS_NULL.getErrorCode()
+ );
+ }
+
+ if (pageable == null) {
+ throw new PageableIllegalArgumentException(
+ ExceptionMessage.PAGEABLE_IS_NULL.getMessage(),
+ ExceptionMessage.PAGEABLE_IS_NULL.getErrorCode()
+ );
+ }
+
+ try (Session session = sessionFactory.openSession()) {
+ Query query = session.createQuery("FROM Task t WHERE t.isCompleted = :status ORDER BY id", Task.class);
+ query.setParameter("status", isCompleted);
+ applyPagination(query, pageable);
+ List tasks = query.list();
+
+ Long total = session.createQuery("SELECT COUNT(t) FROM Task t WHERE t.isCompleted = :status", Long.class)
+ .setParameter("status", isCompleted)
+ .uniqueResult();
+
+ return new PageImpl<>(tasks, pageable, total != null ? total : 0);
+ }
+ }
+
+ @Override
+ public Task update(final Task task) {
+ if (task == null) {
+ throw new EntityIsNullException(
+ ExceptionMessage.ENTITY_IS_NULL.getMessage(),
+ ExceptionMessage.ENTITY_IS_NULL.getErrorCode());
+ }
+ if (task.getId() == null) {
+ throw new TaskInvalidFieldException(
+ ExceptionMessage.ID_IS_NULL.getMessage(),
+ ExceptionMessage.ID_IS_NULL.getErrorCode());
+ }
+ validateTask(task);
+
+ try (Session session = sessionFactory.openSession()) {
+ session.beginTransaction();
+ Task existing = session.get(Task.class, task.getId());
+ if (existing == null) {
+ throw new TaskNotFoundException(
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getMessage(task.getId()),
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getErrorCode());
+ }
+ existing.setTitle(task.getTitle());
+ existing.setDescription(task.getDescription());
+ existing.setIsCompleted(task.getIsCompleted());
+ existing.setUpdatedAt(LocalDateTime.now());
+ session.merge(existing);
+ session.getTransaction().commit();
+ return existing;
+ }
+ }
+
+ @Override
+ public void delete(final Task task) {
+ if (task == null) {
+ throw new EntityIsNullException(
+ ExceptionMessage.ENTITY_IS_NULL.getMessage(),
+ ExceptionMessage.ENTITY_IS_NULL.getErrorCode());
+ }
+ if (task.getId() == null) {
+ throw new TaskInvalidFieldException(
+ ExceptionMessage.ID_IS_NULL.getMessage(),
+ ExceptionMessage.ID_IS_NULL.getErrorCode());
+ }
+
+ try (Session session = sessionFactory.openSession()) {
+ session.beginTransaction();
+ Task toDelete = session.get(Task.class, task.getId());
+ if (toDelete == null) {
+ throw new TaskNotFoundException(
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getMessage(task.getId()),
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getErrorCode());
+ }
+ session.remove(toDelete);
+ session.getTransaction().commit();
+ }
+ }
+
+
+ public void applyPagination(Query> query, Pageable pageable) {
+ query.setFirstResult((int) pageable.getOffset());
+ query.setMaxResults(pageable.getPageSize());
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/repository/JdbcTaskRepository.java b/src/main/java/com/emobile/springtodo/repository/JdbcTaskRepository.java
new file mode 100644
index 00000000..5b0d25cf
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/repository/JdbcTaskRepository.java
@@ -0,0 +1,185 @@
+package com.emobile.springtodo.repository;
+
+import com.emobile.springtodo.entity.Task;
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import com.emobile.springtodo.entity.enumeration.ExceptionMessage;
+import com.emobile.springtodo.entity.enumeration.SqlQuery;
+import com.emobile.springtodo.exception.PageableIllegalArgumentException;
+import com.emobile.springtodo.exception.TaskInvalidFieldException;
+import com.emobile.springtodo.exception.TaskNotFoundException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+
+import java.sql.PreparedStatement;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import static com.emobile.springtodo.validator.TaskRepositoryValidator.validateTask;
+
+@Slf4j
+@RequiredArgsConstructor
+@Repository
+public class JdbcTaskRepository implements TaskRepository {
+
+ private final JdbcTemplate jdbcTemplate;
+
+ private final RowMapper rowMapper = (rs, rowNum) -> {
+ Task task = new Task();
+ task.setId(rs.getLong("id"));
+ task.setTitle(rs.getString("title"));
+ task.setDescription(rs.getString("description"));
+ task.setIsCompleted(CompletionStatus.valueOf(rs.getString("is_completed")));
+ task.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
+ Timestamp updatedAt = rs.getTimestamp("updated_at");
+ if (updatedAt != null) {
+ task.setUpdatedAt(updatedAt.toLocalDateTime());
+ }
+ return task;
+ };
+
+ @Override
+ public Task save(final Task task) {
+ validateTask(task);
+
+ if (task.getId() == null) {
+ KeyHolder keyHolder = new GeneratedKeyHolder();
+ jdbcTemplate.update(connection -> {
+ PreparedStatement ps = connection.prepareStatement(
+ SqlQuery.SAVE.getQuery(),
+ new String[]{"id"}
+ );
+ ps.setString(1, task.getTitle());
+ ps.setString(2, task.getDescription());
+ ps.setString(3, task.getIsCompleted().name());
+ ps.setTimestamp(4, Timestamp.valueOf(task.getCreatedAt()));
+ ps.setTimestamp(5, task.getUpdatedAt() != null ? Timestamp.valueOf(task.getUpdatedAt()) : null);
+ return ps;
+ }, keyHolder);
+
+ Long generatedId = Objects.requireNonNull(keyHolder.getKey()).longValue();
+ task.setId(generatedId);
+ return task;
+ }
+ return update(task);
+ }
+
+ @Override
+ public Optional findById(final Long id) {
+ if (id == null) {
+ throw new TaskInvalidFieldException(
+ ExceptionMessage.ID_IS_NULL.getMessage(),
+ ExceptionMessage.ID_IS_NULL.getErrorCode());
+ }
+
+ try {
+ Task task = jdbcTemplate.queryForObject(
+ SqlQuery.FIND_BY_ID.getQuery(),
+ rowMapper,
+ id
+ );
+ return Optional.ofNullable(task);
+ } catch (Exception e) {
+ return Optional.empty();
+ }
+ }
+
+ @Override
+ public Page findAll(final Pageable pageable) {
+ if (pageable == null) {
+ throw new PageableIllegalArgumentException(
+ ExceptionMessage.PAGEABLE_IS_NULL.getMessage(),
+ ExceptionMessage.PAGEABLE_IS_NULL.getErrorCode());
+ }
+
+ String query = SqlQuery.FIND_ALL.getQuery() + buildPageableClause(pageable);
+ List tasks = jdbcTemplate.query(query, rowMapper);
+ Long total = jdbcTemplate.queryForObject(SqlQuery.COUNT.getQuery(), Long.class);
+
+ return new PageImpl<>(tasks, pageable, total != null ? total : 0);
+ }
+
+ @Override
+ public Page findAllByIsCompleted(final CompletionStatus isCompleted, final Pageable pageable) {
+ if (isCompleted == null) {
+ throw new TaskInvalidFieldException(
+ ExceptionMessage.COMPLETED_IS_NULL.getMessage(),
+ ExceptionMessage.COMPLETED_IS_NULL.getErrorCode());
+ }
+ if (pageable == null) {
+ throw new PageableIllegalArgumentException(
+ ExceptionMessage.PAGEABLE_IS_NULL.getMessage(),
+ ExceptionMessage.PAGEABLE_IS_NULL.getErrorCode());
+ }
+ String query = SqlQuery.FIND_ALL_BY_COMPLETED.getQuery() + buildPageableClause(pageable);
+ List tasks = jdbcTemplate.query(query, rowMapper, isCompleted.name());
+ Long total = jdbcTemplate.queryForObject(
+ SqlQuery.COUNT_BY_COMPLETED.getQuery(),
+ Long.class,
+ isCompleted.name()
+ );
+ return new PageImpl<>(tasks, pageable, total != null ? total : 0);
+ }
+
+ @Override
+ public Task update(final Task task) {
+ validateTask(task);
+
+ if (task.getId() == null) {
+ throw new TaskInvalidFieldException(
+ ExceptionMessage.ID_IS_NULL.getMessage(),
+ ExceptionMessage.ID_IS_NULL.getErrorCode());
+ }
+
+ int updatedRows = jdbcTemplate.update(
+ SqlQuery.UPDATE.getQuery(),
+ task.getTitle(),
+ task.getDescription(),
+ task.getIsCompleted().name(),
+ LocalDateTime.now(),
+ task.getId()
+ );
+
+ if (updatedRows == 0) {
+ throw new TaskNotFoundException(
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getMessage(task.getId()),
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getErrorCode());
+ }
+
+ return task;
+ }
+
+ @Override
+ public void delete(final Task task) {
+ if (task == null) {
+ throw new TaskNotFoundException(
+ ExceptionMessage.ENTITY_IS_NULL.getMessage(),
+ ExceptionMessage.ENTITY_IS_NULL.getErrorCode());
+ }
+
+ int deletedRows = jdbcTemplate.update(SqlQuery.DELETE.getQuery(), task.getId());
+ if (deletedRows == 0) {
+ throw new TaskNotFoundException(
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getMessage(task.getId()),
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getErrorCode());
+ }
+ }
+
+ private String buildPageableClause(final Pageable pageable) {
+ return String.format(
+ " ORDER BY id LIMIT %d OFFSET %d",
+ pageable.getPageSize(),
+ pageable.getPageNumber() * pageable.getPageSize()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/repository/JpaTaskRepository.java b/src/main/java/com/emobile/springtodo/repository/JpaTaskRepository.java
new file mode 100644
index 00000000..7ca92f06
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/repository/JpaTaskRepository.java
@@ -0,0 +1,17 @@
+package com.emobile.springtodo.repository;
+
+import com.emobile.springtodo.entity.Task;
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface JpaTaskRepository extends JpaRepository {
+
+ @Query("SELECT t FROM Task t WHERE t.isCompleted = :status")
+ Page findAllByIsCompleted(@Param("status") CompletionStatus isCompleted, Pageable pageable);
+}
diff --git a/src/main/java/com/emobile/springtodo/repository/TaskRepository.java b/src/main/java/com/emobile/springtodo/repository/TaskRepository.java
new file mode 100644
index 00000000..4fad2b91
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/repository/TaskRepository.java
@@ -0,0 +1,79 @@
+package com.emobile.springtodo.repository;
+
+import com.emobile.springtodo.entity.Task;
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import com.emobile.springtodo.exception.EntityIsNullException;
+import com.emobile.springtodo.exception.PageableIllegalArgumentException;
+import com.emobile.springtodo.exception.TaskInvalidFieldException;
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import java.util.Optional;
+
+/**
+ * Репозиторий для работы с сущностью {@link Task}.
+ * Предоставляет методы для сохранения, поиска, обновления и удаления задач.
+ */
+public interface TaskRepository {
+
+ /**
+ * Сохраняет задачу в базе данных.
+ *
+ * @param task сущность задачи для сохранения.
+ * @return сохранённая задача.
+ * @throws EntityIsNullException если переданная сущность null или некорректна.
+ * @throws DataAccessException если произошла ошибка при сохранении.
+ */
+ Task save(Task task);
+
+ /**
+ * Находит задачу по её идентификатору.
+ *
+ * @param id идентификатор задачи.
+ * @return {@link Optional}, содержащий задачу, если она найдена, или пустой, если не найдена.
+ * @throws TaskInvalidFieldException если идентификатор null.
+ * @throws DataAccessException если произошла ошибка при поиске.
+ */
+ Optional findById(Long id);
+
+ /**
+ * Возвращает все задачи с поддержкой пагинации и сортировки.
+ *
+ * @param pageable объект, содержащий параметры пагинации и сортировки.
+ * @return страница с задачами.
+ * @throws PageableIllegalArgumentException если параметры пагинации некорректны.
+ * @throws DataAccessException если произошла ошибка при поиске.
+ */
+ Page findAll(Pageable pageable);
+
+ /**
+ * Находит задачи по статусу выполнения с поддержкой пагинации и сортировки.
+ *
+ * @param isCompleted статус выполнения задачи.
+ * @param pageable объект, содержащий параметры пагинации и сортировки.
+ * @return страница с задачами, соответствующими указанному статусу.
+ * @throws PageableIllegalArgumentException если параметры пагинации или статус некорректны.
+ * @throws DataAccessException если произошла ошибка при поиске.
+ */
+ Page findAllByIsCompleted(CompletionStatus isCompleted, Pageable pageable);
+
+ /**
+ * Обновляет существующую задачу в базе данных.
+ *
+ * @param task сущность задачи с обновлёнными данными
+ * @return обновлённая задача
+ * @throws EntityIsNullException если переданная сущность null или некорректна
+ * @throws DataAccessException если произошла ошибка при обновлении
+ */
+ Task update(Task task);
+
+ /**
+ * Удаляет задачу из базы данных.
+ *
+ * @param task сущность задачи для удаления
+ * @throws EntityIsNullException если переданная сущность null
+ * @throws DataAccessException если произошла ошибка при удалении
+ */
+ void delete(Task task);
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/service/TaskService.java b/src/main/java/com/emobile/springtodo/service/TaskService.java
new file mode 100644
index 00000000..5ccf20a9
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/service/TaskService.java
@@ -0,0 +1,76 @@
+package com.emobile.springtodo.service;
+
+import com.emobile.springtodo.entity.dto.TaskRequest;
+import com.emobile.springtodo.entity.dto.TaskResponse;
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import com.emobile.springtodo.exception.*;
+import org.springframework.data.domain.Page;
+
+/**
+ * Сервис для управления задачами.
+ * Предоставляет методы для создания, поиска, обновления и удаления задач.
+ */
+public interface TaskService {
+
+ /**
+ * Создаёт новую задачу на основе переданного запроса.
+ *
+ * @param taskRequest объект запроса, содержащий данные для создания задачи.
+ * @return созданная задача.
+ * @throws UniqueViolationException если сущность совпадает с уже существующей по id, title.
+ * @throws EntityIsNullException если переданная сущность null или некорректна.
+ */
+ TaskResponse create(TaskRequest taskRequest);
+
+ /**
+ * Находит задачу по её идентификатору.
+ *
+ * @param id идентификатор задачи.
+ * @return объект ответа с данными задачи.
+ * @throws TaskNotFoundException если задача с указанным идентификатором не найдена.
+ */
+ TaskResponse findById(Long id);
+
+ /**
+ * Возвращает список всех задач с поддержкой пагинации.
+ *
+ * @param page номер страницы (начинается с 0).
+ * @param offset количество задач на странице.
+ * @return страница с объектами ответа, содержащими данные задач.
+ * @throws PageableIllegalArgumentException если параметры пагинации некорректны.
+ */
+ Page findAll(Integer page, Integer offset);
+
+ /**
+ * Находит задачи по статусу выполнения с поддержкой пагинации.
+ *
+ * @param isCompleted статус выполнения задачи.
+ * @param page номер страницы (начинается с 0).
+ * @param offset количество задач на странице.
+ * @return страница с объектами ответа, содержащими данные задач.
+ * @throws TaskInvalidFieldException если статус выполнения задачи некорректен.
+ * @throws PageableIllegalArgumentException если параметры пагинации некорректны.
+ */
+ Page findAllByIsCompleted(CompletionStatus isCompleted, Integer page, Integer offset);
+
+ /**
+ * Обновляет существующую задачу на основе переданного запроса.
+ *
+ * @param taskRequest объект запроса с обновлёнными данными задачи.
+ * @param id идентификатор задачи.
+ * @return обновлённая задача.
+ * @throws TaskNotFoundException если задача с указанным идентификатором не найдена.
+ * @throws TaskInvalidFieldException если данные в запросе некорректны.
+ */
+ TaskResponse update(TaskRequest taskRequest, Long id);
+
+ /**
+ * Удаляет задачу по её идентификатору.
+ *
+ * @param id идентификатор задачи.
+ * @return удалённая задача.
+ * @throws TaskNotFoundException если задача с указанным идентификатором не найдена.
+ * @throws TaskInvalidFieldException если данные в запросе некорректны.
+ */
+ TaskResponse delete(Long id);
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/service/impl/TaskServiceImpl.java b/src/main/java/com/emobile/springtodo/service/impl/TaskServiceImpl.java
new file mode 100644
index 00000000..12c32aa5
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/service/impl/TaskServiceImpl.java
@@ -0,0 +1,144 @@
+package com.emobile.springtodo.service.impl;
+
+import com.emobile.springtodo.entity.Task;
+import com.emobile.springtodo.entity.dto.TaskRequest;
+import com.emobile.springtodo.entity.dto.TaskResponse;
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import com.emobile.springtodo.entity.enumeration.ExceptionMessage;
+import com.emobile.springtodo.exception.TaskNotFoundException;
+import com.emobile.springtodo.mapper.TaskMapper;
+import com.emobile.springtodo.repository.HibernateTaskRepository;
+import com.emobile.springtodo.repository.JpaTaskRepository;
+import com.emobile.springtodo.service.TaskService;
+import com.emobile.springtodo.validator.TaskRequestValidator;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.CachePut;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TaskServiceImpl implements TaskService {
+
+ private final TaskMapper taskMapper;
+
+// private final TaskRepository taskRepository;
+// private final HibernateTaskRepository taskRepository;
+ private final JpaTaskRepository taskRepository;
+
+ private final TaskRequestValidator taskRequestValidator;
+
+ private final MeterRegistry meterRegistry;
+
+ @Override
+ @Transactional
+ @CachePut(value = "tasks", key = "#result.id")
+ public TaskResponse create(final TaskRequest taskRequest) {
+ taskRequestValidator.validate(taskRequest);
+
+ log.info("REST request to save Task : {}", taskRequest);
+
+ final Task taskToCreate = taskMapper.fromRequestToEntity(taskRequest);
+ log.info("TaskRequest has been successfully mapped for creation method. Task entity: {}", taskToCreate);
+
+ final Task createdTask = taskRepository.save(taskToCreate);
+ log.info("Task has been successfully created. Task entity: {}", createdTask);
+
+ Counter.builder("tasks.created").register(meterRegistry).increment();
+
+ return taskMapper.fromEntityToResponse(createdTask);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ @Cacheable(value = "tasks", key = "#id")
+ public TaskResponse findById(final Long id) {
+ log.info("REST request to get Task by id: {}", id);
+
+ final Task task = taskRepository.findById(id).orElseThrow(() ->
+ new TaskNotFoundException(
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getMessage(id),
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getErrorCode())
+ );
+ log.info("Task by id has been successfully found. Task entity: {}", task);
+
+ return taskMapper.fromEntityToResponse(task);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ @Cacheable(value = "tasks", key = "'all_page_' + #page + '_offset_' + #offset")
+ public Page findAll(final Integer page, final Integer offset) {
+ log.info("REST request to get all Tasks by page: {}", page);
+ final PageRequest pageRequest = PageRequest.of(page, offset);
+
+ final Page taskPage = taskRepository.findAll(pageRequest);
+ log.info("Tasks has been successfully found. Task entities: {}", taskPage);
+
+ return taskPage.map(taskMapper::fromEntityToResponse);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ @Cacheable(value = "tasks", key = "'completed_' + #isCompleted + '_page_' + #page + '_offset_' + #offset")
+ public Page findAllByIsCompleted(final CompletionStatus isCompleted, final Integer page, final Integer offset) {
+ log.info("REST request to get all Tasks by isCompleted: {}", isCompleted);
+ final PageRequest pageRequest = PageRequest.of(page, offset);
+
+ final Page taskPage = taskRepository.findAllByIsCompleted(isCompleted, pageRequest);
+ log.info("Tasks by isCompleted has been successfully found. Task entities: {}", taskPage);
+
+ return taskPage.map(taskMapper::fromEntityToResponse);
+ }
+
+ @Override
+ @Transactional
+ @CachePut(value = "tasks", key = "#result.id")
+ public TaskResponse update(final TaskRequest taskRequest, final Long id) {
+
+ taskRequestValidator.validate(taskRequest);
+
+ log.info("REST request to update Task by id: {}", id);
+ final Task taskToUpdate = taskRepository.findById(id).orElseThrow(() ->
+ new TaskNotFoundException(
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getMessage(id),
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getErrorCode())
+ );
+ log.info("Task to update has been successfully found. Task entity: {}", taskToUpdate);
+
+ final Task updatedTask = taskMapper.updateEntityFromRequest(taskToUpdate, taskRequest);
+ log.info("Task to update has been successfully mapped. Task entity: {}", updatedTask);
+
+ taskRepository.save(updatedTask);
+ log.info("Task has been successfully saved. Task entity: {}", updatedTask);
+
+ return taskMapper.fromEntityToResponse(updatedTask);
+ }
+
+ @Override
+ @Transactional
+ @CacheEvict(value = "tasks", key = "#id")
+ public TaskResponse delete(final Long id) {
+ log.info("REST request to delete Task by id: {}", id);
+
+ final Task taskToDelete = taskRepository.findById(id).orElseThrow(() ->
+ new TaskNotFoundException(
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getMessage(id),
+ ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getErrorCode())
+ );
+ log.info("Task to delete has been successfully found. Task entity: {}", taskToDelete);
+
+ taskRepository.delete(taskToDelete);
+ log.info("Task has been successfully deleted. Task entity: {}", taskToDelete);
+
+ return taskMapper.fromEntityToResponse(taskToDelete);
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/validator/TaskRepositoryValidator.java b/src/main/java/com/emobile/springtodo/validator/TaskRepositoryValidator.java
new file mode 100644
index 00000000..9134771b
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/validator/TaskRepositoryValidator.java
@@ -0,0 +1,27 @@
+package com.emobile.springtodo.validator;
+
+import com.emobile.springtodo.entity.Task;
+import com.emobile.springtodo.entity.enumeration.ExceptionMessage;
+import com.emobile.springtodo.exception.EntityIsNullException;
+import com.emobile.springtodo.exception.TaskInvalidFieldException;
+
+public class TaskRepositoryValidator {
+
+ public static void validateTask(Task task) {
+ if (task == null) {
+ throw new EntityIsNullException(
+ ExceptionMessage.ENTITY_IS_NULL.getMessage(),
+ ExceptionMessage.ENTITY_IS_NULL.getErrorCode());
+ }
+ if (task.getTitle() == null || task.getTitle().trim().isEmpty()) {
+ throw new TaskInvalidFieldException(
+ ExceptionMessage.TITLE_IS_NULL_OR_EMPTY.getMessage(task.getTitle()),
+ ExceptionMessage.TITLE_IS_NULL_OR_EMPTY.getErrorCode());
+ }
+ if (task.getCreatedAt() == null) {
+ throw new TaskInvalidFieldException(
+ ExceptionMessage.CREATED_AT_IS_NULL.getMessage(),
+ ExceptionMessage.CREATED_AT_IS_NULL.getErrorCode());
+ }
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/validator/TaskRequestValidator.java b/src/main/java/com/emobile/springtodo/validator/TaskRequestValidator.java
new file mode 100644
index 00000000..05cd9d70
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/validator/TaskRequestValidator.java
@@ -0,0 +1,30 @@
+package com.emobile.springtodo.validator;
+
+import com.emobile.springtodo.entity.dto.TaskRequest;
+import com.emobile.springtodo.entity.enumeration.ExceptionMessage;
+import com.emobile.springtodo.exception.TaskInvalidFieldException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class TaskRequestValidator {
+
+ public void validate(final TaskRequest request) {
+
+ if (request.title() == null || request.title().trim().isEmpty()) {
+ throw new TaskInvalidFieldException(
+ ExceptionMessage.TITLE_IS_NULL_OR_EMPTY.getMessage("null"),
+ ExceptionMessage.TITLE_IS_NULL_OR_EMPTY.getErrorCode()
+ );
+ }
+
+ if (request.description() == null || request.description().trim().isEmpty()) {
+ throw new TaskInvalidFieldException(
+ ExceptionMessage.DESCRIPTION_IS_NULL_OR_EMPTY.getMessage("null"),
+ ExceptionMessage.DESCRIPTION_IS_NULL_OR_EMPTY.getErrorCode()
+ );
+ }
+ }
+
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
deleted file mode 100644
index 5c3e5461..00000000
--- a/src/main/resources/application.properties
+++ /dev/null
@@ -1 +0,0 @@
-spring.application.name=SpringToDo
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 00000000..954b1723
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,85 @@
+server:
+ port: 8088
+# error:
+# include-binding-errors: always
+# include-exception: true
+# include-message: always
+
+
+spring:
+ application:
+ name: 'spring-todo'
+
+ datasource:
+ driver-class-name: org.postgresql.Driver
+ hikari:
+ username: postgres
+ password: postgres
+ minimum-idle: 5
+ maximum-pool-size: 10
+ idle-timeout: 600_000
+ max-lifetime: 1_800_000
+ connection-timeout: 30_000
+ url: jdbc:postgresql://localhost:5432/springtodo
+
+ jpa:
+ hibernate:
+ ddl-auto: create-drop
+
+ flyway:
+ enabled: true
+ user: postgres
+ password: postgres
+ schemas: public
+ url: jdbc:postgresql://localhost:5432/springtodo
+ locations: "classpath:db/migration"
+ baseline-on-migrate: true
+
+ data:
+ redis:
+ host: localhost
+ port: 6379
+ cache:
+ type: redis
+ redis:
+ time-to-live: 60000
+ cache-null-values: true
+
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,metrics,info
+ endpoint:
+ health:
+ show-details: always
+ metrics:
+ enabled: true
+ info:
+ enabled: true
+
+springdoc:
+ info:
+ title: Spring Tasks
+ version: 0.0.1
+ description: This API exposes endpoints for to do tasks.
+ servers:
+ - url: http://localhost:8088
+ description: Local Server.
+ - url: http://localhost:8088
+ description: Dev Server.
+ - url: http://localhost:8088
+ description: Prod Server.
+ api-docs:
+ enabled: true
+ path: /spring-todo-api-docs
+ swagger-ui:
+ enabled: true
+ path: /spring-todo-ui
+ try-it-out-enabled: true
+ operations-sorter: method
+ tags-sorter: alpha
+ filter: true
+ packages-to-scan:
+ - "com.emobile.springtodo"
\ No newline at end of file
diff --git a/src/main/resources/db/migration/V1__CREATE_TASKS_TABLE.sql b/src/main/resources/db/migration/V1__CREATE_TASKS_TABLE.sql
new file mode 100644
index 00000000..680489ed
--- /dev/null
+++ b/src/main/resources/db/migration/V1__CREATE_TASKS_TABLE.sql
@@ -0,0 +1,22 @@
+CREATE TABLE IF NOT EXISTS tasks
+(
+ id BIGSERIAL PRIMARY KEY,
+ title VARCHAR(255) NOT NULL,
+ description TEXT,
+ is_completed VARCHAR(50) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP,
+ CONSTRAINT check_is_completed CHECK (is_completed IN ('NOT_COMPLETED', 'COMPLETED'))
+);
+
+-- Индекс для ускорения поиска по статусу выполнения
+CREATE INDEX idx_tasks_is_completed ON tasks (is_completed);
+
+-- Комментарии к столбцам для документации
+COMMENT ON TABLE tasks IS 'Таблица для хранения задач';
+COMMENT ON COLUMN tasks.id IS 'Уникальный идентификатор задачи';
+COMMENT ON COLUMN tasks.title IS 'Заголовок задачи';
+COMMENT ON COLUMN tasks.description IS 'Описание задачи';
+COMMENT ON COLUMN tasks.is_completed IS 'Статус выполнения задачи (NOT_COMPLETED, COMPLETED)';
+COMMENT ON COLUMN tasks.created_at IS 'Дата и время создания задачи';
+COMMENT ON COLUMN tasks.updated_at IS 'Дата и время последнего обновления задачи';
\ No newline at end of file
diff --git a/src/test/java/com/emobile/springtodo/SpringToDoApplicationTests.java b/src/test/java/com/emobile/springtodo/SpringToDoApplicationTests.java
deleted file mode 100644
index 3114a50b..00000000
--- a/src/test/java/com/emobile/springtodo/SpringToDoApplicationTests.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.emobile.springtodo;
-
-import org.junit.jupiter.api.Test;
-import org.springframework.boot.test.context.SpringBootTest;
-
-@SpringBootTest
-class SpringToDoApplicationTests {
-
- @Test
- void contextLoads() {
- }
-
-}
diff --git a/src/test/java/com/emobile/springtodo/controller/TaskControllerIntegrationTest.java b/src/test/java/com/emobile/springtodo/controller/TaskControllerIntegrationTest.java
new file mode 100644
index 00000000..7de86718
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/controller/TaskControllerIntegrationTest.java
@@ -0,0 +1,216 @@
+package com.emobile.springtodo.controller;
+
+import com.emobile.springtodo.SpringToDoApplication;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.skyscreamer.jsonassert.JSONAssert;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest(classes = SpringToDoApplication.class)
+@AutoConfigureMockMvc
+@Testcontainers
+public class TaskControllerIntegrationTest {
+
+ @Container
+ @ServiceConnection
+ static PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
+ .withDatabaseName("testdb")
+ .withUsername("test")
+ .withPassword("test");
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ @DisplayName("Интеграционный тест: Создание новой задачи - успешный сценарий")
+ @Sql(scripts = {"classpath:/schema.sql", "classpath:drop-data.sql"})
+ public void testCreateTask_Success() throws Exception {
+ String requestJson = """
+ {
+ "title": "New Task",
+ "description": "New Description",
+ "isCompleted": "NOT_COMPLETED"
+ }
+ """;
+
+ String expectedJson = """
+ {
+ "data": {
+ "title": "New Task",
+ "description": "New Description",
+ "isCompleted": "NOT_COMPLETED"
+ },
+ "errorCode": null,
+ "errorMessage": null,
+ "status": "OK"
+ }
+ """;
+ String result = mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/tasks")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestJson))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+
+ JSONAssert.assertEquals(expectedJson, result, false);
+ }
+
+ @Test
+ @DisplayName("Интеграционный тест: Поиск задачи по ID - успешный сценарий")
+ @Sql(scripts = {"classpath:/schema.sql", "classpath:drop-data.sql", "classpath:insert-test-data.sql"})
+ public void testFindById_Success() throws Exception {
+ String expectedJson = """
+ {
+ "data": {
+ "id": 1,
+ "title": "Predefined Task",
+ "description": "Description 1",
+ "isCompleted": "NOT_COMPLETED"
+ },
+ "errorCode": null,
+ "errorMessage": null,
+ "status": "OK"
+ }
+ """;
+
+ String result = mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/tasks/1"))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+
+ JSONAssert.assertEquals(expectedJson, result, false);
+ }
+
+ @Test
+ @DisplayName("Интеграционный тест: Получение всех задач с пагинацией - успешный сценарий")
+ @Sql(scripts = {"classpath:/schema.sql", "classpath:drop-data.sql", "classpath:insert-test-data.sql"})
+ public void testFindAll_Success() throws Exception {
+ String expectedJson = """
+ {
+ "data": {
+ "content": [
+ {"id":1, "title":"Predefined Task", "description":"Description 1", "isCompleted":"NOT_COMPLETED"},
+ {"id":2, "title":"Predefined Task 2", "description":"Description 2", "isCompleted":"COMPLETED"}
+ ],
+ "totalElements": 2
+ },
+ "errorCode": null,
+ "errorMessage": null,
+ "status": "OK"
+ }
+ """;
+
+ String result = mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/tasks/all")
+ .param("page", "0")
+ .param("offset", "10"))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+
+ JSONAssert.assertEquals(expectedJson, result, false);
+ }
+
+ @Test
+ @DisplayName("Интеграционный тест: Получение задач по статусу завершения - успешный сценарий")
+ @Sql(scripts = {"classpath:/schema.sql", "classpath:drop-data.sql", "classpath:insert-test-data.sql"})
+ public void testFindAllByIsCompleted_Success() throws Exception {
+ String expectedJson = """
+ {
+ "data": {
+ "content": [
+ {"id":2, "title":"Predefined Task 2", "description":"Description 2", "isCompleted":"COMPLETED"}
+ ],
+ "totalElements": 1
+ },
+ "errorCode": null,
+ "errorMessage": null,
+ "status": "OK"
+ }
+ """;
+
+ String result = mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/tasks/by-is-completed")
+ .param("isCompleted", "COMPLETED")
+ .param("page", "0")
+ .param("offset", "10"))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+
+ JSONAssert.assertEquals(expectedJson, result, false);
+ }
+
+ @Test
+ @DisplayName("Интеграционный тест: Обновление задачи - успешный сценарий")
+ @Sql(scripts = {"classpath:/schema.sql", "classpath:drop-data.sql", "classpath:insert-test-data.sql"})
+ public void testUpdateTask_Success() throws Exception {
+ String requestJson = """
+ {
+ "title": "Updated Task",
+ "description": "Updated Description",
+ "isCompleted": "COMPLETED"
+ }
+ """;
+
+ String expectedJson = """
+ {
+ "data": {
+ "id": 1,
+ "title": "Updated Task",
+ "description": "Updated Description",
+ "isCompleted": "COMPLETED"
+ },
+ "errorCode": null,
+ "errorMessage": null,
+ "status": "OK"
+ }
+ """;
+
+ String result = mockMvc.perform(MockMvcRequestBuilders.put("/api/v1/tasks/1")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestJson))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+
+ JSONAssert.assertEquals(expectedJson, result, false);
+ }
+
+ @Test
+ @DisplayName("Интеграционный тест: Удаление задачи - успешный сценарий")
+ @Sql(scripts = {"classpath:/schema.sql", "classpath:drop-data.sql", "classpath:insert-test-data.sql"})
+ public void testDeleteTask_Success() throws Exception {
+ String expectedJson = """
+ {
+ "data": {
+ "id": 1,
+ "title": "Predefined Task",
+ "description": "Description 1",
+ "isCompleted": "NOT_COMPLETED",
+ "createdAt":"2025-10-17T10:00:00",
+ "updatedAt":null
+ },
+ "errorCode": null,
+ "errorMessage": null,
+ "status": "OK"
+ }
+ """;
+
+ String result = mockMvc.perform(MockMvcRequestBuilders.delete("/api/v1/tasks/1"))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+
+ JSONAssert.assertEquals(expectedJson, result, false);
+
+ mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/tasks/1"))
+ .andExpect(status().isOk());
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/test/java/com/emobile/springtodo/controller/TaskControllerTest.java b/src/test/java/com/emobile/springtodo/controller/TaskControllerTest.java
new file mode 100644
index 00000000..43a1f2af
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/controller/TaskControllerTest.java
@@ -0,0 +1,151 @@
+package com.emobile.springtodo.controller;
+
+import com.emobile.springtodo.entity.dto.TaskRequest;
+import com.emobile.springtodo.entity.dto.TaskResponse;
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import com.emobile.springtodo.service.TaskService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+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.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.time.LocalDateTime;
+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.*;
+
+@ExtendWith(MockitoExtension.class)
+
+class TaskControllerTest {
+
+ @Mock
+ private TaskService taskService;
+
+ @InjectMocks
+ private TaskController taskController;
+
+ private MockMvc mockMvc;
+ private ObjectMapper objectMapper;
+ private TaskRequest taskRequest;
+ private TaskResponse taskResponse;
+ private Page taskResponsePage;
+
+ @BeforeEach
+ void setUp() {
+ mockMvc = MockMvcBuilders.standaloneSetup(taskController).build();
+ objectMapper = new ObjectMapper();
+ objectMapper.registerModule(new JavaTimeModule());
+
+ taskRequest = new TaskRequest(
+ "Test Task",
+ "Test Description",
+ CompletionStatus.NOT_COMPLETED,
+ LocalDateTime.now(),
+ null
+ );
+
+ taskResponse = new TaskResponse(
+ 1L,
+ "Test Task",
+ "Test Description",
+ CompletionStatus.NOT_COMPLETED,
+ LocalDateTime.now(),
+ LocalDateTime.now()
+ );
+
+ taskResponsePage = new PageImpl<>(List.of(taskResponse), PageRequest.of(0, 10), 1);
+ }
+
+ @Test
+ void createTask_Success_ReturnsCreatedTask() throws Exception {
+ when(taskService.create(any(TaskRequest.class))).thenReturn(taskResponse);
+
+ mockMvc.perform(post("/api/v1/tasks")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(taskRequest)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.id").value(taskResponse.id()))
+ .andExpect(jsonPath("$.data.title").value(taskResponse.title()))
+ .andExpect(jsonPath("$.data.description").value(taskResponse.description()))
+ .andExpect(jsonPath("$.data.isCompleted").value(taskResponse.isCompleted().toString()));
+
+ verify(taskService).create(any(TaskRequest.class));
+ }
+
+ @Test
+ void findById_Success_ReturnsTask() throws Exception {
+ when(taskService.findById(1L)).thenReturn(taskResponse);
+
+ mockMvc.perform(get("/api/v1/tasks/1")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.id").value(taskResponse.id()))
+ .andExpect(jsonPath("$.data.title").value(taskResponse.title()))
+ .andExpect(jsonPath("$.data.description").value(taskResponse.description()))
+ .andExpect(jsonPath("$.data.isCompleted").value(taskResponse.isCompleted().toString()));
+
+ verify(taskService).findById(1L);
+ }
+
+ @Test
+ void findAll_Success_ReturnsPagedTasks() throws Exception {
+ when(taskService.findAll(0, 10)).thenReturn(taskResponsePage);
+
+ mockMvc.perform(get("/api/v1/tasks/all")
+ .param("page", "0")
+ .param("offset", "10")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.content[0].id").value(taskResponse.id()))
+ .andExpect(jsonPath("$.data.content[0].title").value(taskResponse.title()))
+ .andExpect(jsonPath("$.data.totalElements").value(1));
+
+ verify(taskService).findAll(0, 10);
+ }
+
+ @Test
+ void findAllByIsCompleted_Success_ReturnsPagedTasks() throws Exception {
+ when(taskService.findAllByIsCompleted(CompletionStatus.NOT_COMPLETED, 0, 10))
+ .thenReturn(taskResponsePage);
+
+ mockMvc.perform(get("/api/v1/tasks/by-is-completed")
+ .param("isCompleted", CompletionStatus.NOT_COMPLETED.toString())
+ .param("page", "0")
+ .param("offset", "10")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.content[0].id").value(taskResponse.id()))
+ .andExpect(jsonPath("$.data.content[0].title").value(taskResponse.title()))
+ .andExpect(jsonPath("$.data.totalElements").value(1));
+
+ verify(taskService).findAllByIsCompleted(CompletionStatus.NOT_COMPLETED, 0, 10);
+ }
+
+ @Test
+ void deleteTask_Success_ReturnsDeletedTask() throws Exception {
+ when(taskService.delete(1L)).thenReturn(taskResponse);
+
+ mockMvc.perform(delete("/api/v1/tasks/1")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.id").value(taskResponse.id()))
+ .andExpect(jsonPath("$.data.title").value(taskResponse.title()))
+ .andExpect(jsonPath("$.data.description").value(taskResponse.description()))
+ .andExpect(jsonPath("$.data.isCompleted").value(taskResponse.isCompleted().toString()));
+
+ verify(taskService).delete(1L);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/emobile/springtodo/repository/HibernateTaskRepositoryTest.java b/src/test/java/com/emobile/springtodo/repository/HibernateTaskRepositoryTest.java
new file mode 100644
index 00000000..15c015ff
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/repository/HibernateTaskRepositoryTest.java
@@ -0,0 +1,237 @@
+package com.emobile.springtodo.repository;
+
+import com.emobile.springtodo.entity.Task;
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import com.emobile.springtodo.exception.EntityIsNullException;
+import com.emobile.springtodo.exception.PageableIllegalArgumentException;
+import com.emobile.springtodo.exception.TaskInvalidFieldException;
+import com.emobile.springtodo.exception.TaskNotFoundException;
+import org.hibernate.SessionFactory;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.transaction.annotation.Transactional;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@SpringBootTest
+@Transactional
+@Testcontainers
+class HibernateTaskRepositoryTest {
+
+ @Container
+ @ServiceConnection
+ static PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
+ .withDatabaseName("testdb")
+ .withUsername("test")
+ .withPassword("test");
+
+ @Autowired
+ private HibernateTaskRepository repository;
+
+ @Autowired
+ private SessionFactory sessionFactory;
+
+ private Task createValidTask() {
+ Task task = new Task();
+ task.setTitle("Test Task");
+ task.setDescription("Description");
+ task.setIsCompleted(CompletionStatus.NOT_COMPLETED);
+ task.setCreatedAt(LocalDateTime.now());
+ return task;
+ }
+
+ @BeforeEach
+ void setUp() {
+ try (var session = sessionFactory.openSession()) {
+ session.beginTransaction();
+ session.createQuery("DELETE FROM Task").executeUpdate();
+ session.getTransaction().commit();
+ }
+ }
+
+ @Test
+ void save_shouldSaveNewTaskAndAssignId() {
+ // Arrange
+ Task task = createValidTask();
+
+ // Act
+ Task savedTask = repository.save(task);
+
+ // Assert
+ assertNotNull(savedTask.getId(), "Saved task should have an ID");
+ assertEquals(task.getTitle(), savedTask.getTitle());
+ assertEquals(task.getDescription(), savedTask.getDescription());
+ assertEquals(task.getIsCompleted(), savedTask.getIsCompleted());
+ assertNotNull(savedTask.getCreatedAt());
+ }
+
+ @Test
+ void save_shouldThrowEntityIsNullException_forNullTask() {
+ // Act & Assert
+ EntityIsNullException exception = assertThrows(EntityIsNullException.class, () -> repository.save(null));
+ assertEquals("Entity is null", exception.getMessage());
+ }
+
+ @Test
+ void save_shouldThrowTaskInvalidFieldException_forNullTitle() {
+ // Arrange
+ Task task = createValidTask();
+ task.setTitle(null);
+
+ // Act & Assert
+ TaskInvalidFieldException exception = assertThrows(TaskInvalidFieldException.class, () -> repository.save(task));
+ assertEquals("Task title is null or empty: null", exception.getMessage());
+ }
+
+ @Test
+ void findById_shouldReturnTask_whenExists() {
+ // Arrange
+ Task task = createValidTask();
+ task = repository.save(task);
+
+ // Act
+ Optional found = repository.findById(task.getId());
+
+ // Assert
+ assertTrue(found.isPresent());
+ assertEquals(task.getId(), found.get().getId());
+ assertEquals(task.getTitle(), found.get().getTitle());
+ }
+
+ @Test
+ void findById_shouldReturnEmpty_whenNotExists() {
+ // Act
+ Optional found = repository.findById(999L);
+
+ // Assert
+ assertFalse(found.isPresent());
+ }
+
+ @Test
+ void findById_shouldThrowTaskInvalidFieldException_forNullId() {
+ // Act & Assert
+ TaskInvalidFieldException exception = assertThrows(TaskInvalidFieldException.class, () -> repository.findById(null));
+ assertEquals("Task id is null", exception.getMessage());
+ }
+
+ @Test
+ void findAll_shouldReturnPagedTasks() {
+ // Arrange
+ Task task1 = createValidTask();
+ Task task2 = createValidTask();
+ task2.setTitle("Task 2");
+ repository.save(task1);
+ repository.save(task2);
+ Pageable pageable = PageRequest.of(0, 1);
+
+ // Act
+ Page page = repository.findAll(pageable);
+
+ // Assert
+ assertEquals(1, page.getContent().size());
+ assertEquals(2, page.getTotalElements());
+ assertEquals(task1.getTitle(), page.getContent().getFirst().getTitle());
+ }
+
+ @Test
+ void findAll_shouldThrowPageableIllegalArgumentException_forNullPageable() {
+ // Act & Assert
+ PageableIllegalArgumentException exception = assertThrows(PageableIllegalArgumentException.class, () -> repository.findAll(null));
+ assertEquals("Pageable is null", exception.getMessage());
+ }
+
+ @Test
+ void findAllByIsCompleted_shouldReturnPagedTasksByStatus() {
+ // Arrange
+ Task task1 = createValidTask();
+ Task task2 = createValidTask();
+ task2.setIsCompleted(CompletionStatus.COMPLETED);
+ repository.save(task1);
+ repository.save(task2);
+ Pageable pageable = PageRequest.of(0, 10);
+
+ // Act
+ Page page = repository.findAllByIsCompleted(CompletionStatus.NOT_COMPLETED, pageable);
+
+ // Assert
+ assertEquals(1, page.getContent().size());
+ assertEquals(1, page.getTotalElements());
+ assertEquals(CompletionStatus.NOT_COMPLETED, page.getContent().getFirst().getIsCompleted());
+ }
+
+ @Test
+ void findAllByIsCompleted_shouldThrowTaskInvalidFieldException_forNullStatus() {
+ // Act & Assert
+ TaskInvalidFieldException exception = assertThrows(TaskInvalidFieldException.class, () -> repository.findAllByIsCompleted(null, PageRequest.of(0, 10)));
+ assertEquals("Task isCompleted is null", exception.getMessage());
+ }
+
+ @Test
+ void update_shouldUpdateExistingTask() {
+ // Arrange
+ Task task = createValidTask();
+ task = repository.save(task);
+ task.setTitle("Updated Title");
+ task.setDescription("Updated Description");
+ task.setIsCompleted(CompletionStatus.COMPLETED);
+
+ // Act
+ Task updated = repository.update(task);
+
+ // Assert
+ Optional found = repository.findById(task.getId());
+ assertTrue(found.isPresent());
+ assertEquals("Updated Title", found.get().getTitle());
+ assertEquals("Updated Description", found.get().getDescription());
+ assertEquals(CompletionStatus.COMPLETED, found.get().getIsCompleted());
+ assertNotNull(found.get().getUpdatedAt());
+ }
+
+ @Test
+ void update_shouldThrowTaskNotFoundException_forNonExistentTask() {
+ // Arrange
+ Task task = createValidTask();
+ task.setId(999L);
+
+ // Act & Assert
+ TaskNotFoundException exception = assertThrows(TaskNotFoundException.class, () -> repository.update(task));
+ assertEquals("Entity not found by id: 999", exception.getMessage());
+ }
+
+ @Test
+ void delete_shouldRemoveTask() {
+ // Arrange
+ Task task = createValidTask();
+ task = repository.save(task);
+
+ // Act
+ repository.delete(task);
+
+ // Assert
+ Optional found = repository.findById(task.getId());
+ assertFalse(found.isPresent());
+ }
+
+ @Test
+ void delete_shouldThrowTaskNotFoundException_forNonExistentTask() {
+ // Arrange
+ Task task = createValidTask();
+ task.setId(999L);
+
+ // Act & Assert
+ TaskNotFoundException exception = assertThrows(TaskNotFoundException.class, () -> repository.delete(task));
+ assertEquals("Entity not found by id: 999", exception.getMessage());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/emobile/springtodo/repository/JdbcTaskRepositoryTest.java b/src/test/java/com/emobile/springtodo/repository/JdbcTaskRepositoryTest.java
new file mode 100644
index 00000000..2312587f
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/repository/JdbcTaskRepositoryTest.java
@@ -0,0 +1,180 @@
+package com.emobile.springtodo.repository;
+
+import com.emobile.springtodo.entity.Task;
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import com.emobile.springtodo.entity.enumeration.ExceptionMessage;
+import com.emobile.springtodo.entity.enumeration.SqlQuery;
+import com.emobile.springtodo.exception.EntityIsNullException;
+import com.emobile.springtodo.exception.PageableIllegalArgumentException;
+import com.emobile.springtodo.exception.TaskInvalidFieldException;
+import com.emobile.springtodo.exception.TaskNotFoundException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class JdbcTaskRepositoryTest {
+
+ @Mock
+ private JdbcTemplate jdbcTemplate;
+
+ private JdbcTaskRepository taskRepository;
+
+ @BeforeEach
+ void setUp() {
+ taskRepository = new JdbcTaskRepository(jdbcTemplate);
+ }
+
+ @Test
+ @DisplayName("Создание таски: успешно возвращает созданную таску.")
+ void save_shouldCreateNewTask_whenIdIsNull() {
+ // Arrange: Подготовка задачи без ID
+ Task task = new Task();
+ task.setTitle("Test Title");
+ task.setDescription("Test Description");
+ task.setIsCompleted(CompletionStatus.NOT_COMPLETED);
+ task.setCreatedAt(LocalDateTime.now());
+ task.setUpdatedAt(null);
+
+ // Мокаем update с KeyHolder
+ when(jdbcTemplate.update(any(), any(KeyHolder.class))).thenAnswer(invocation -> {
+ // Симулируем генерацию ID
+ ((GeneratedKeyHolder) invocation.getArgument(1)).getKeyList().add(Collections.singletonMap("id", 1L));
+ return 1; // Успешная вставка
+ });
+
+ // Act: Вызов save
+ Task savedTask = taskRepository.save(task);
+
+ // Assert: Проверяем, что ID сгенерирован, задача возвращена
+ assertNotNull(savedTask.getId());
+ assertEquals(1L, savedTask.getId());
+
+ // Захватываем аргументы для проверки SQL
+ verify(jdbcTemplate).update(any(), any(KeyHolder.class));
+ // Здесь можно дополнительно проверить параметры PreparedStatement, но для простоты опустим
+ }
+
+ @Test
+ @DisplayName("Создание таски: выбрасывает EntityIsNullException, когда передаём null вместо таски.")
+ void save_shouldThrowException_whenTaskIsNull() {
+ // Arrange & Act & Assert: Проверяем валидацию
+ EntityIsNullException exception = assertThrows(EntityIsNullException.class, () -> taskRepository.save(null));
+ assertEquals(ExceptionMessage.ENTITY_IS_NULL.getMessage(), exception.getMessage());
+ }
+
+ @Test
+ @DisplayName("Создание таски: выбрасывает TaskInvalidFieldException, когда передаём пустую таску.")
+ void save_shouldThrowException_whenTitleIsEmpty() {
+ // Arrange: Задача с пустым title
+ Task task = new Task();
+ task.setTitle(""); // Пустой
+ task.setCreatedAt(LocalDateTime.now());
+
+ // Act & Assert
+ TaskInvalidFieldException exception = assertThrows(TaskInvalidFieldException.class, () -> taskRepository.save(task));
+ assertEquals(ExceptionMessage.TITLE_IS_NULL_OR_EMPTY.getMessage(""), exception.getMessage());
+ }
+
+ @Test
+ @DisplayName("Ищем таску по id: возвращает пустой Optional.")
+ void findById_shouldReturnEmpty_whenIdNotFound() {
+ // Arrange: Мокаем пустой результат (throw EmptyResultDataAccessException, но мы catch в коде)
+ when(jdbcTemplate.queryForObject(anyString(), any(RowMapper.class), anyLong()))
+ .thenThrow(new RuntimeException("No result")); // Симулируем отсутствие
+
+ // Act
+ Optional result = taskRepository.findById(1L);
+
+ // Assert
+ assertFalse(result.isPresent());
+ }
+
+ @Test
+ @DisplayName("Ищем таску по id: выбрасывает TaskInvalidFieldException, при условии что id = null.")
+ void findById_shouldThrowException_whenIdIsNull() {
+ // Act & Assert
+ TaskInvalidFieldException exception = assertThrows(TaskInvalidFieldException.class, () -> taskRepository.findById(null));
+ assertEquals(ExceptionMessage.ID_IS_NULL.getMessage(), exception.getMessage());
+ }
+
+ @Test
+ @DisplayName("Найти все таски: возвращает paged результат.")
+ void findAll_shouldReturnPagedTasks() {
+ // Arrange: Pageable
+ Pageable pageable = PageRequest.of(0, 10);
+ List tasks = List.of(new Task(1L, "Title1", "Desc1", CompletionStatus.NOT_COMPLETED, LocalDateTime.now(), null));
+ when(jdbcTemplate.query(anyString(), any(RowMapper.class))).thenReturn(tasks);
+ when(jdbcTemplate.queryForObject(SqlQuery.COUNT.getQuery(), Long.class)).thenReturn(1L);
+
+ // Act
+ Page result = taskRepository.findAll(pageable);
+
+ // Assert
+ assertEquals(1, result.getTotalElements());
+ assertEquals(tasks, result.getContent());
+ verify(jdbcTemplate).query(contains("LIMIT 10 OFFSET 0"), any(RowMapper.class));
+ }
+
+ @Test
+ @DisplayName("Найти все таски: выбрасывает PageableIllegalArgumentException, когда pageable = null.")
+ void findAll_shouldThrowException_whenPageableIsNull() {
+ // Act & Assert
+ PageableIllegalArgumentException exception = assertThrows(PageableIllegalArgumentException.class, () -> taskRepository.findAll(null));
+ assertEquals(ExceptionMessage.PAGEABLE_IS_NULL.getMessage(), exception.getMessage());
+ }
+
+ @Test
+ @DisplayName("Обновить таску: выбрасывает TaskNotFoundException, когда не можем найти таску.")
+ void update_shouldThrowNotFound_whenNoRowsUpdated() {
+ // Arrange
+ Task task = new Task(1L, "Title", "Desc", CompletionStatus.NOT_COMPLETED, LocalDateTime.now(), null);
+ when(jdbcTemplate.update(anyString(), any(), any(), any(), any(), anyLong())).thenReturn(0); // Нет обновлений
+
+ // Act & Assert
+ TaskNotFoundException exception = assertThrows(TaskNotFoundException.class, () -> taskRepository.update(task));
+ assertEquals(ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getMessage(1L), exception.getMessage());
+ }
+
+ @Test
+ @DisplayName("Удалить таску: выбрасывает TaskNotFoundException, когда не можем найти таску.")
+ void delete_shouldThrowNotFound_whenNoRowsDeleted() {
+ // Arrange
+ Task task = new Task(1L, "Title", "Desc", CompletionStatus.NOT_COMPLETED, LocalDateTime.now(), null);
+ when(jdbcTemplate.update(anyString(), anyLong())).thenReturn(0);
+
+ // Act & Assert
+ TaskNotFoundException exception = assertThrows(TaskNotFoundException.class, () -> taskRepository.delete(task));
+ assertEquals(ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getMessage(1L), exception.getMessage());
+ }
+
+ @Test
+ @DisplayName("Удалить таску: выбрасывает EntityIsNullException, когда передаём null.")
+ void delete_shouldThrowException_whenTaskIsNull() {
+ // Act & Assert
+ TaskNotFoundException exception = assertThrows(TaskNotFoundException.class, () -> taskRepository.delete(null));
+ assertEquals(ExceptionMessage.ENTITY_IS_NULL.getMessage(), exception.getMessage());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/emobile/springtodo/repository/JpaTaskRepositoryTest.java b/src/test/java/com/emobile/springtodo/repository/JpaTaskRepositoryTest.java
new file mode 100644
index 00000000..936145ec
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/repository/JpaTaskRepositoryTest.java
@@ -0,0 +1,181 @@
+package com.emobile.springtodo.repository;
+
+import com.emobile.springtodo.entity.Task;
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.transaction.annotation.Transactional;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@SpringBootTest
+@Transactional
+@Testcontainers
+class JpaTaskRepositoryTest {
+
+ @Container
+ @ServiceConnection
+ static PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
+ .withDatabaseName("testdb")
+ .withUsername("test")
+ .withPassword("test");
+
+ @Autowired
+ private JpaTaskRepository repository;
+
+ private Task createValidTask() {
+ Task task = new Task();
+ task.setTitle("Test Task");
+ task.setDescription("Description");
+ task.setIsCompleted(CompletionStatus.NOT_COMPLETED);
+ task.setCreatedAt(LocalDateTime.now());
+ return task;
+ }
+
+ @BeforeEach
+ void setUp() {
+ repository.deleteAll();
+ }
+
+ @Test
+ void save_shouldSaveNewTaskAndAssignId() {
+ // Arrange
+ Task task = createValidTask();
+
+ // Act
+ Task savedTask = repository.save(task);
+
+ // Assert
+ assertNotNull(savedTask.getId(), "Saved task should have an ID");
+ assertEquals(task.getTitle(), savedTask.getTitle());
+ assertEquals(task.getDescription(), savedTask.getDescription());
+ assertEquals(task.getIsCompleted(), savedTask.getIsCompleted());
+ assertNotNull(savedTask.getCreatedAt());
+ }
+
+ @Test
+ void save_shouldThrowException_forNullTask() {
+ // Act & Assert
+ assertThrows(Exception.class, () -> repository.save(null));
+ }
+
+ @Test
+ void save_shouldThrowException_forNullTitle() {
+ // Arrange
+ Task task = createValidTask();
+ task.setTitle(null);
+
+ // Act & Assert
+ assertThrows(Exception.class, () -> repository.save(task));
+ }
+
+ @Test
+ void findById_shouldReturnTask_whenExists() {
+ // Arrange
+ Task task = createValidTask();
+ task = repository.save(task);
+
+ // Act
+ Optional found = repository.findById(task.getId());
+
+ // Assert
+ assertTrue(found.isPresent());
+ assertEquals(task.getId(), found.get().getId());
+ assertEquals(task.getTitle(), found.get().getTitle());
+ }
+
+ @Test
+ void findById_shouldReturnEmpty_whenNotExists() {
+ // Act
+ Optional found = repository.findById(999L);
+
+ // Assert
+ assertFalse(found.isPresent());
+ }
+
+ @Test
+ void findAll_shouldReturnPagedTasks() {
+ // Arrange
+ Task task1 = createValidTask();
+ Task task2 = createValidTask();
+ task2.setTitle("Task 2");
+ repository.save(task1);
+ repository.save(task2);
+ Pageable pageable = PageRequest.of(0, 1);
+
+ // Act
+ Page page = repository.findAll(pageable);
+
+ // Assert
+ assertEquals(1, page.getContent().size());
+ assertEquals(2, page.getTotalElements());
+ assertEquals(task1.getTitle(), page.getContent().get(0).getTitle());
+ }
+
+ @Test
+ void findAllByIsCompleted_shouldReturnPagedTasksByStatus() {
+ // Arrange
+ Task task1 = createValidTask();
+ Task task2 = createValidTask();
+ task2.setIsCompleted(CompletionStatus.COMPLETED);
+ repository.save(task1);
+ repository.save(task2);
+ Pageable pageable = PageRequest.of(0, 10);
+
+ // Act
+ Page page = repository.findAllByIsCompleted(CompletionStatus.NOT_COMPLETED, pageable);
+
+ // Assert
+ assertEquals(1, page.getContent().size());
+ assertEquals(1, page.getTotalElements());
+ assertEquals(CompletionStatus.NOT_COMPLETED, page.getContent().get(0).getIsCompleted());
+ }
+
+ @Test
+ void update_shouldUpdateExistingTask() {
+ // Arrange
+ Task task = createValidTask();
+ task = repository.save(task);
+ task.setTitle("Updated Title");
+ task.setDescription("Updated Description");
+ task.setIsCompleted(CompletionStatus.COMPLETED);
+ task.setUpdatedAt(LocalDateTime.now());
+
+ // Act
+ Task updated = repository.save(task);
+
+ // Assert
+ Optional found = repository.findById(task.getId());
+ assertTrue(found.isPresent());
+ assertEquals("Updated Title", found.get().getTitle());
+ assertEquals("Updated Description", found.get().getDescription());
+ assertEquals(CompletionStatus.COMPLETED, found.get().getIsCompleted());
+ assertNotNull(found.get().getUpdatedAt());
+ }
+
+ @Test
+ void delete_shouldRemoveTask() {
+ // Arrange
+ Task task = createValidTask();
+ task = repository.save(task);
+
+ // Act
+ repository.deleteById(task.getId());
+
+ // Assert
+ Optional found = repository.findById(task.getId());
+ assertFalse(found.isPresent());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/emobile/springtodo/service/TaskServiceImplTest.java b/src/test/java/com/emobile/springtodo/service/TaskServiceImplTest.java
new file mode 100644
index 00000000..7afd9975
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/service/TaskServiceImplTest.java
@@ -0,0 +1,218 @@
+package com.emobile.springtodo.service;
+
+import com.emobile.springtodo.entity.Task;
+import com.emobile.springtodo.entity.dto.TaskRequest;
+import com.emobile.springtodo.entity.dto.TaskResponse;
+import com.emobile.springtodo.entity.enumeration.CompletionStatus;
+import com.emobile.springtodo.entity.enumeration.ExceptionMessage;
+import com.emobile.springtodo.exception.TaskInvalidFieldException;
+import com.emobile.springtodo.exception.TaskNotFoundException;
+import com.emobile.springtodo.mapper.TaskMapper;
+import com.emobile.springtodo.repository.JpaTaskRepository;
+import com.emobile.springtodo.service.impl.TaskServiceImpl;
+import com.emobile.springtodo.validator.TaskRequestValidator;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class TaskServiceImplTest {
+
+ @Mock
+ private TaskMapper taskMapper;
+
+// @Mock
+// private TaskRepository taskRepository;
+
+// @Mock
+// private HibernateTaskRepository taskRepository;
+
+ @Mock
+ private JpaTaskRepository taskRepository;
+
+ @Mock
+ private TaskRequestValidator taskRequestValidator;
+
+ @Mock
+ private MeterRegistry meterRegistry;
+
+ @Mock
+ private Counter counter;
+
+ private TaskServiceImpl taskService;
+
+ @BeforeEach
+ void setUp() {
+ when(meterRegistry.counter("tasks.created")).thenReturn(counter);
+ taskService = new TaskServiceImpl(taskMapper, taskRepository, taskRequestValidator, meterRegistry);
+ }
+
+ @Test
+ @DisplayName("Создание задачи: выбрасывает исключение при невалидном запросе")
+ void create_shouldThrowException_whenRequestInvalid() {
+ // Arrange
+ TaskRequest request = new TaskRequest(null, "Desc", CompletionStatus.NOT_COMPLETED, LocalDateTime.now(), null);
+ doThrow(new TaskInvalidFieldException("Invalid title", "400")).when(taskRequestValidator).validate(request);
+
+ // Act & Assert
+ TaskInvalidFieldException exception = assertThrows(TaskInvalidFieldException.class, () -> taskService.create(request));
+ assertEquals("Invalid title", exception.getMessage());
+ verifyNoInteractions(taskRepository, taskMapper);
+ }
+
+ @Test
+ @DisplayName("Поиск задачи по ID: возвращает задачу, если ID существует")
+ void findById_shouldReturnTask_whenIdExists() {
+ // Arrange
+ Long id = 1L;
+ Task task = new Task(id, "Title", "Desc", CompletionStatus.COMPLETED, LocalDateTime.now(), null);
+ TaskResponse response = new TaskResponse(id, "Title", "Desc", CompletionStatus.COMPLETED, LocalDateTime.now(), null);
+
+ when(taskRepository.findById(id)).thenReturn(Optional.of(task));
+ when(taskMapper.fromEntityToResponse(task)).thenReturn(response);
+
+ // Act
+ TaskResponse result = taskService.findById(id);
+
+ // Assert
+ assertEquals(response, result);
+ verify(taskRepository).findById(id);
+ verify(taskMapper).fromEntityToResponse(task);
+ }
+
+ @Test
+ @DisplayName("Поиск задачи по ID: выбрасывает исключение, если задача не найдена")
+ void findById_shouldThrowNotFound_whenIdNotExists() {
+ // Arrange
+ Long id = 1L;
+ when(taskRepository.findById(id)).thenReturn(Optional.empty());
+
+ // Act & Assert
+ TaskNotFoundException exception = assertThrows(TaskNotFoundException.class, () -> taskService.findById(id));
+ assertEquals(ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getMessage(id), exception.getMessage());
+ verify(taskRepository).findById(id);
+ verifyNoMoreInteractions(taskMapper);
+ }
+
+ @Test
+ @DisplayName("Получение всех задач: возвращает страницу задач")
+ void findAll_shouldReturnPagedTasks() {
+ // Arrange
+ Integer page = 0;
+ Integer offset = 10;
+ PageRequest pageRequest = PageRequest.of(page, offset);
+ List tasks = List.of(new Task(1L, "Title1", "Desc1", CompletionStatus.NOT_COMPLETED, LocalDateTime.now(), null));
+ Page taskPage = new PageImpl<>(tasks, pageRequest, 1);
+ TaskResponse response = new TaskResponse(1L, "Title1", "Desc1", CompletionStatus.NOT_COMPLETED, LocalDateTime.now(), null);
+ Page expectedPage = new PageImpl<>(List.of(response), pageRequest, 1);
+
+ when(taskRepository.findAll(pageRequest)).thenReturn(taskPage);
+ when(taskMapper.fromEntityToResponse(any(Task.class))).thenReturn(response);
+
+ // Act
+ Page result = taskService.findAll(page, offset);
+
+ // Assert
+ assertEquals(expectedPage.getTotalElements(), result.getTotalElements());
+ assertEquals(expectedPage.getContent(), result.getContent());
+ verify(taskRepository).findAll(pageRequest);
+ }
+
+ @Test
+ @DisplayName("Получение задач по статусу завершения: возвращает страницу задач")
+ void findAllByIsCompleted_shouldReturnPagedTasks() {
+ // Arrange
+ CompletionStatus status = CompletionStatus.COMPLETED;
+ Integer page = 0;
+ Integer offset = 10;
+ PageRequest pageRequest = PageRequest.of(page, offset);
+ List tasks = List.of(new Task(1L, "Title", "Desc", status, LocalDateTime.now(), null));
+ Page taskPage = new PageImpl<>(tasks, pageRequest, 1);
+ TaskResponse response = new TaskResponse(1L, "Title", "Desc", status, LocalDateTime.now(), null);
+ Page expectedPage = new PageImpl<>(List.of(response), pageRequest, 1);
+
+ when(taskRepository.findAllByIsCompleted(status, pageRequest)).thenReturn(taskPage);
+ when(taskMapper.fromEntityToResponse(any(Task.class))).thenReturn(response);
+
+ // Act
+ Page result = taskService.findAllByIsCompleted(status, page, offset);
+
+ // Assert
+ assertEquals(expectedPage.getTotalElements(), result.getTotalElements());
+ assertEquals(expectedPage.getContent(), result.getContent());
+ verify(taskRepository).findAllByIsCompleted(status, pageRequest);
+ }
+
+ @Test
+ @DisplayName("Обновление задачи: выбрасывает исключение, если задача не найдена")
+ void update_shouldThrowNotFound_whenIdNotExists() {
+ // Arrange
+ Long id = 1L;
+ TaskRequest request = new TaskRequest("Title", "Desc", CompletionStatus.NOT_COMPLETED, LocalDateTime.now(), null);
+ doNothing().when(taskRequestValidator).validate(request);
+ when(taskRepository.findById(id)).thenReturn(Optional.empty());
+
+ // Act & Assert
+ TaskNotFoundException exception = assertThrows(TaskNotFoundException.class, () -> taskService.update(request, id));
+ assertEquals(ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getMessage(id), exception.getMessage());
+ verify(taskRepository).findById(id);
+ verifyNoMoreInteractions(taskMapper, taskRepository);
+ }
+
+ @Test
+ @DisplayName("Удаление задачи: успешно удаляет задачу, если ID существует")
+ void delete_shouldDeleteTask_whenIdExists() {
+ // Arrange
+ Long id = 1L;
+ Task task = new Task(id, "Title", "Desc", CompletionStatus.NOT_COMPLETED, LocalDateTime.now(), null);
+ TaskResponse response = new TaskResponse(id, "Title", "Desc", CompletionStatus.NOT_COMPLETED, LocalDateTime.now(), null);
+
+ when(taskRepository.findById(id)).thenReturn(Optional.of(task));
+ doNothing().when(taskRepository).delete(task);
+ when(taskMapper.fromEntityToResponse(task)).thenReturn(response);
+
+ // Act
+ TaskResponse result = taskService.delete(id);
+
+ // Assert
+ assertEquals(response, result);
+ verify(taskRepository).findById(id);
+ verify(taskRepository).delete(task);
+ verify(taskMapper).fromEntityToResponse(task);
+ }
+
+ @Test
+ @DisplayName("Удаление задачи: выбрасывает исключение, если задача не найдена")
+ void delete_shouldThrowNotFound_whenIdNotExists() {
+ // Arrange
+ Long id = 1L;
+ when(taskRepository.findById(id)).thenReturn(Optional.empty());
+
+ // Act & Assert
+ TaskNotFoundException exception = assertThrows(TaskNotFoundException.class, () -> taskService.delete(id));
+ assertEquals(ExceptionMessage.ENTITY_NOT_FOUND_BY_ID.getMessage(id), exception.getMessage());
+ verify(taskRepository).findById(id);
+ verifyNoInteractions(taskMapper);
+ verifyNoMoreInteractions(taskRepository);
+ }
+}
\ No newline at end of file
diff --git a/src/test/resources/drop-data.sql b/src/test/resources/drop-data.sql
new file mode 100644
index 00000000..c01a3be1
--- /dev/null
+++ b/src/test/resources/drop-data.sql
@@ -0,0 +1 @@
+DELETE FROM tasks;
\ No newline at end of file
diff --git a/src/test/resources/insert-test-data.sql b/src/test/resources/insert-test-data.sql
new file mode 100644
index 00000000..79ade616
--- /dev/null
+++ b/src/test/resources/insert-test-data.sql
@@ -0,0 +1,12 @@
+INSERT INTO tasks (id, title, description, is_completed, created_at)
+VALUES (1,
+ 'Predefined Task',
+ 'Description 1',
+ 'NOT_COMPLETED',
+ '2025-10-17 10:00:00'),
+ (2,
+ 'Predefined Task 2',
+ 'Description 2',
+ 'COMPLETED',
+ '2025-10-17 10:00:00'
+ );
\ No newline at end of file
diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql
new file mode 100644
index 00000000..64b4ec13
--- /dev/null
+++ b/src/test/resources/schema.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS tasks
+(
+ id BIGSERIAL PRIMARY KEY,
+ title VARCHAR(255) NOT NULL,
+ description TEXT,
+ is_completed VARCHAR(50) NOT NULL,
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP
+);
\ No newline at end of file