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