diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 00000000..056f7410 --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,65 @@ +name: Java CI with Maven + +on: + push: + branches: [ "dev" ] + pull_request: + branches: [ "dev" ] + +jobs: + build: + runs-on: ubuntu-latest + + # Переменные окружения для всего job + env: + SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/tododb + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: postgres + + services: + postgres: + image: postgres:15 + ports: + - 5432:5432 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: tododb + options: >- + --health-cmd="pg_isready -U postgres -d tododb" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + + - name: Build with Maven + run: mvn -B package --file pom.xml + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/java-app:dev-latest + + - name: Verify Docker image + run: docker run --rm ${{ secrets.DOCKER_USERNAME }}/java-app:dev-latest + + - name: Update dependency graph + uses: advanced-security/maven-dependency-submission-action@571e99aab1055c2e71a1e2309b9691de18d6b7d6 \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..3781d0c9 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,48 @@ +stages: + - test + - build + - docker +test: + stage: test + image: maven:3.8-openjdk-17 + script: + - mvn clean test + only: + - merge_requests + - dev + except: + - master +cache: + paths: + - .m2/repository/ + - target/ +build: + stage: build + image: maven:3.8.6-openjdk-17 + script: + - mvn clean package + artifacts: + paths: + - target/*.jar + expire_in: 1 hour + only: + - merge_requests + - dev +docker-build: + stage: docker + image: docker:20.10.16 + services: + - docker:20.10.16-dind + variables: + DOCKER_HOST: tcp://docker:2376 + DOCKER_TLS_VERIFY: 1 + DOCKER_TLS_CERTDIR: "/certs" + before_script: + - docker login -u "$DOCKER_REGISTRY_USER" -p "$DOCKER_REGISTRY_PASSWORD" + script: + - docker build -t $DOCKER_IMAGE_NAME:latest . + - docker push $DOCKER_IMAGE_NAME:latest + only: + - master + dependencies: + - build diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..434a6a18 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Используем официальный образ JDK +FROM eclipse-temurin:21-jdk + +# Указываем рабочую директорию внутри контейнера +WORKDIR /app + +# Копируем собранный jar-файл +COPY target/spring-todo-app-0.0.1-SNAPSHOT.jar app.jar + +# Открываем порт 8080 +EXPOSE 8080 + +# Команда запуска приложения +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..4b57ae10 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.8' +services: + postgres: + image: postgres:15 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: tododb + ports: + - "5432:5432" + redis: + image: redis:7 + ports: + - "6379:6379" + app: + build: . + depends_on: + - postgres + - redis + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/tododb + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: postgres + SPRING_REDIS_HOST: redis + ports: + - "8080:8080" \ No newline at end of file diff --git a/pom.xml b/pom.xml index 03bc446d..eb7005f5 100644 --- a/pom.xml +++ b/pom.xml @@ -1,41 +1,200 @@ - - + 4.0.0 + + com.example + spring-todo-app + 0.0.1-SNAPSHOT + jar + org.springframework.boot spring-boot-starter-parent - 3.3.5 + 3.3.2 - com.emobile - SpringToDo - 0.0.1-SNAPSHOT - SpringToDo - SpringToDo - 21 + 17 + 1.5.5.Final + 1.18.30 + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-testcontainers + 3.3.8 + test + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + org.springframework.boot + spring-boot-starter + org.springframework.boot spring-boot-starter + + org.springframework.boot + spring-boot-starter-web + 3.4.1 + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.postgresql + postgresql + + + org.projectlombok + lombok + 1.18.28 + provided + + + jakarta.validation + jakarta.validation-api + + + org.junit.jupiter + junit-jupiter + 5.10.0 + test + + + org.mockito + mockito-core + 5.5.0 + test + + + org.mockito + mockito-junit-jupiter + 5.5.0 + test + + + org.assertj + assertj-core + 3.24.2 + test + + + org.skyscreamer + jsonassert + 1.5.1 + test + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + org.testcontainers + postgresql + 1.19.0 + test + org.springframework.boot spring-boot-starter-test test - + + org.testcontainers + junit-jupiter + 1.19.0 + test + + + + org.mapstruct + mapstruct + 1.5.5.Final + + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + org.projectlombok + lombok + ${lombok.version} + provided + + + org.hibernate.orm + hibernate-core + 6.4.4.Final + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.10.1 + + 17 + 17 + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + + + org.projectlombok + lombok + 1.18.30 + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + + + org.sonarsource.scanner.maven + sonar-maven-plugin + 3.10.0.2594 + org.springframework.boot spring-boot-maven-plugin + 2.7.0 + + + + repackage + + + + + com.emobile.springtodo.SpringToDoApplication + diff --git a/src/main/java/com/emobile/springtodo/config/ActuatorConfig.java b/src/main/java/com/emobile/springtodo/config/ActuatorConfig.java new file mode 100644 index 00000000..9da5413c --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/ActuatorConfig.java @@ -0,0 +1,31 @@ +package com.emobile.springtodo.config; + +import org.springframework.boot.actuate.info.InfoContributor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +import java.util.Arrays; +import java.util.Map; + +@Configuration +public class ActuatorConfig { + + @Bean + public InfoContributor appInfoContributor() { + return builder -> builder.withDetail("application", Map.of( + "name", "My Spring Boot App", + "version", "1.0.0", + "description", "Custom Spring Boot Application" + )); + } + + @Bean + public InfoContributor environmentInfoContributor(Environment env) { + return builder -> builder.withDetail("environment", Map.of( + "activeProfiles", Arrays.asList(env.getActiveProfiles()), + "javaVersion", System.getProperty("java.version"), + "os", System.getProperty("os.name") + )); + } +} diff --git a/src/main/java/com/emobile/springtodo/config/CacheConfig.java b/src/main/java/com/emobile/springtodo/config/CacheConfig.java new file mode 100644 index 00000000..747c0a96 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/CacheConfig.java @@ -0,0 +1,21 @@ +package com.emobile.springtodo.config; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; + +@Configuration +@EnableCaching +public class CacheConfig { + + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()) + .build(); + } +} diff --git a/src/main/java/com/emobile/springtodo/config/HibernateConfig.java b/src/main/java/com/emobile/springtodo/config/HibernateConfig.java new file mode 100644 index 00000000..86579978 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/HibernateConfig.java @@ -0,0 +1,25 @@ +package com.emobile.springtodo.config; + +import org.hibernate.SessionFactory; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class HibernateConfig { + + @Bean + public SessionFactory sessionFactory() { + + StandardServiceRegistry registry = new StandardServiceRegistryBuilder() + .configure("hibernate.cfg.xml") + .build(); + + + return new MetadataSources(registry) + .buildMetadata() + .buildSessionFactory(); + } +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/controller/api/ToDoApi.java b/src/main/java/com/emobile/springtodo/controller/api/ToDoApi.java new file mode 100644 index 00000000..853577b3 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/api/ToDoApi.java @@ -0,0 +1,46 @@ +package com.emobile.springtodo.controller.api; + + +import com.emobile.springtodo.dto.ToDoDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "ToDo API", description = "Операции для управления задачами") +@RequestMapping("/api/todos") +public interface ToDoApi { + + @Operation(summary = "Получить задачу по ID") + @ApiResponse(responseCode = "200", description = "Задача найдена", + content = @Content(schema = @Schema(implementation = ToDoDto.class))) + @GetMapping("/{id}") + ToDoDto getById(@PathVariable Long id); + + @Operation(summary = "Создать задачу") + @ApiResponse(responseCode = "201", description = "Задача создана", + content = @Content(schema = @Schema(implementation = ToDoDto.class))) + @PostMapping + ToDoDto create( @RequestBody ToDoDto dto); + + @Operation(summary = "Обновить задачу") + @ApiResponse(responseCode = "200", description = "Задача обновлена", + content = @Content(schema = @Schema(implementation = ToDoDto.class))) + @PutMapping("/{id}") + ToDoDto update(@PathVariable Long id, @RequestBody ToDoDto dto); + + @Operation(summary = "Удалить задачу") + @ApiResponse(responseCode = "204", description = "Задача удалена") + @DeleteMapping("/{id}") + void delete(@PathVariable Long id); + +} diff --git a/src/main/java/com/emobile/springtodo/controller/api/ToDoController.java b/src/main/java/com/emobile/springtodo/controller/api/ToDoController.java new file mode 100644 index 00000000..0a3ca0c9 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/api/ToDoController.java @@ -0,0 +1,47 @@ +package com.emobile.springtodo.controller.api; + + +import com.emobile.springtodo.dto.ToDoDto; +import com.emobile.springtodo.service.ToDoService; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/todos") +@AllArgsConstructor +public class ToDoController implements ToDoApi { + + private ToDoService toDoService; + + + + @Override + public ToDoDto getById(@PathVariable Long id) { + return toDoService.getById(id); + } + + @Override + public ToDoDto create(@RequestBody ToDoDto toDoDto) { + + return toDoService.create(toDoDto); + } + + @Override + public ToDoDto update(Long id, ToDoDto dto) { + + return toDoService.update(id, dto); + } + + @Override + public void delete(Long id) { + + toDoService.delete(id); + } + + +} diff --git a/src/main/java/com/emobile/springtodo/dto/ToDoDto.java b/src/main/java/com/emobile/springtodo/dto/ToDoDto.java new file mode 100644 index 00000000..83fa9174 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/ToDoDto.java @@ -0,0 +1,25 @@ +package com.emobile.springtodo.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ToDoDto implements Serializable{ + + private Long id; + @NotBlank(message = "Title must not be blank") + @Size(max = 50, message = "Title must be at most 50 characters") + private String title; + @Size(max = 100, message = "Description must be at most 1000 characters") + private String description; + private Boolean completed; +} diff --git a/src/main/java/com/emobile/springtodo/exception/ErrorResponse.java b/src/main/java/com/emobile/springtodo/exception/ErrorResponse.java new file mode 100644 index 00000000..f57b0192 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/ErrorResponse.java @@ -0,0 +1,30 @@ +package com.emobile.springtodo.exception; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.Map; + +@Getter +@Setter +public class ErrorResponse { + + private int status; + private String error; + private String message; + private Map details; + private LocalDateTime timestamp = LocalDateTime.now(); + + + public ErrorResponse(int status, String error, String message, Map details) { + this.status = status; + this.error = error; + this.message = message; + this.details = details; + } + + public ErrorResponse(int status, String error, String message) { + this(status, error, message, null); + } +} diff --git a/src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java b/src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..addf1b52 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java @@ -0,0 +1,33 @@ +package com.emobile.springtodo.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(TaskNotFoundException.class) + public ResponseEntity handleNotFound(TaskNotFoundException ex) { + ErrorResponse errorResponse = new ErrorResponse( + HttpStatus.NOT_FOUND.value(), + "Task Not Found", + ex.getMessage() + ); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); + } + + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneral(Exception ex) { + ErrorResponse errorResponse = new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "Internal Server Error", + ex.getMessage() + ); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } +} 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..17f407e6 --- /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 RuntimeException { + + public TaskNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java b/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java new file mode 100644 index 00000000..ed89a249 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java @@ -0,0 +1,12 @@ +package com.emobile.springtodo.mapper; + +import com.emobile.springtodo.dto.ToDoDto; +import com.emobile.springtodo.model.ToDo; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface ToDoMapper { + + ToDoDto toDto(ToDo entity); + ToDo toEntity(ToDoDto dto); +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/metrics/TaskMetrics.java b/src/main/java/com/emobile/springtodo/metrics/TaskMetrics.java new file mode 100644 index 00000000..fea7ba40 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/metrics/TaskMetrics.java @@ -0,0 +1,18 @@ +package com.emobile.springtodo.metrics; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.stereotype.Component; + +@Component +public class TaskMetrics { + + private final MeterRegistry meterRegistry; + + public TaskMetrics(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + public void incrementCompletedTasks() { + meterRegistry.counter("tasks.completed").increment(); + } +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/model/ToDo.java b/src/main/java/com/emobile/springtodo/model/ToDo.java new file mode 100644 index 00000000..b9a82818 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/ToDo.java @@ -0,0 +1,37 @@ +package com.emobile.springtodo.model; + + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.CustomLog; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Data +@Table(name = "todo") +@NoArgsConstructor +@AllArgsConstructor +public class ToDo { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "title") + private String title; + @Column(name = "description") + private String description; + @Column(name = "completed") + private Boolean completed; + @Column(name = "created_at") + private LocalDateTime createdAt; + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/emobile/springtodo/repository/ToDoDao.java b/src/main/java/com/emobile/springtodo/repository/ToDoDao.java new file mode 100644 index 00000000..8b123a92 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/ToDoDao.java @@ -0,0 +1,84 @@ +package com.emobile.springtodo.repository; + +import com.emobile.springtodo.model.ToDo; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.Transaction; + +import java.time.LocalDateTime; +import java.util.List; + + +public class ToDoDao { + + private SessionFactory sessionFactory; + + public ToDoDao(SessionFactory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + public Long save(ToDo todo) { + Session session = sessionFactory.openSession(); + Transaction tx = null; + Long id = null; + + try { + tx = session.beginTransaction(); + + todo.setCreatedAt(LocalDateTime.now()); + todo.setUpdatedAt(LocalDateTime.now()); + + id = (Long) session.save(todo); + + tx.commit(); + } catch (Exception e) { + if (tx != null) tx.rollback(); + throw e; + } finally { + session.close(); + } + + return id; + } + + public ToDo getById(Long id) { + try (Session session = sessionFactory.openSession()) { + return session.get(ToDo.class, id); + } + } + + public ToDo update(ToDo todo) { + Transaction tx = null; + try (Session session = sessionFactory.openSession()) { + tx = session.beginTransaction(); + + todo.setUpdatedAt(LocalDateTime.now()); + + session.update(todo); + + tx.commit(); + return todo; + } catch (Exception e) { + if (tx != null) tx.rollback(); + throw e; + } + } + + public void deleteById(Long id) { + Transaction tx = null; + + try (Session session = sessionFactory.openSession()) { + tx = session.beginTransaction(); + + ToDo todo = session.get(ToDo.class, id); + if (todo != null) { + session.delete(todo); + } + + tx.commit(); + } catch (Exception e) { + if (tx != null) tx.rollback(); + throw e; + } + } +} diff --git a/src/main/java/com/emobile/springtodo/service/ToDoService.java b/src/main/java/com/emobile/springtodo/service/ToDoService.java new file mode 100644 index 00000000..f36264ba --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/ToDoService.java @@ -0,0 +1,17 @@ +package com.emobile.springtodo.service; + +import com.emobile.springtodo.dto.ToDoDto; + + +public interface ToDoService { + + ToDoDto create(ToDoDto toDoDto); + + ToDoDto update(Long id, ToDoDto toDoDto); + + ToDoDto getById(Long id); + + + void delete(Long id); + +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/service/ToDoServiceImpl.java b/src/main/java/com/emobile/springtodo/service/ToDoServiceImpl.java new file mode 100644 index 00000000..c3427eac --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/ToDoServiceImpl.java @@ -0,0 +1,75 @@ +package com.emobile.springtodo.service; + +import com.emobile.springtodo.dto.ToDoDto; +import com.emobile.springtodo.exception.TaskNotFoundException; +import com.emobile.springtodo.mapper.ToDoMapper; +import com.emobile.springtodo.model.ToDo; +import com.emobile.springtodo.repository.ToDoDao; +import org.hibernate.SessionFactory; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class ToDoServiceImpl implements ToDoService { + + + private final ToDoDao toDoDao; + private final ToDoMapper mapper; + private final ToDoMapper toDoMapper; + + public ToDoServiceImpl(SessionFactory sessionFactory, ToDoMapper mapper, + ToDoMapper toDoMapper) { + this.toDoDao = new ToDoDao(sessionFactory); + this.mapper = mapper; + this.toDoMapper = toDoMapper; + } + + public ToDoDto create(ToDoDto dto) { + + ToDo entity = mapper.toEntity(dto); + Long id = toDoDao.save(entity); + ToDo byId = toDoDao.getById(id); + return mapper.toDto(byId); + } + + @Override + public ToDoDto update(Long id, ToDoDto toDoDto) { + + ToDo existingTodo = toDoDao.getById(id); + if (existingTodo == null) { + throw new TaskNotFoundException("Задача с id: " + id + " не найдена"); + } + + if (toDoDto.getId() != null && !existingTodo.getId().equals(toDoDto.getId())) { + throw new TaskNotFoundException("ID в пути и в теле запроса не совпадают"); + } + + + existingTodo.setTitle(toDoDto.getTitle()); + existingTodo.setDescription(toDoDto.getDescription()); + existingTodo.setUpdatedAt(LocalDateTime.now()); + + + toDoDao.update(existingTodo); + + + return mapper.toDto(existingTodo); + } + + @Override + public ToDoDto getById(Long id) { + + ToDo byId = toDoDao.getById(id); + ToDoDto dto = mapper.toDto(byId); + + return dto; + } + + @Override + public void delete(Long id) { + + toDoDao.deleteById(id); + } +} 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..fc2d45d7 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,96 @@ +server: + port: 8080 + servlet: + context-path: / + +spring: + application: + name: todo-service + + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/tododb + username: postgres + password: postgres + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + idle-timeout: 30000 + connection-timeout: 30000 + + liquibase: + change-log: classpath:db/changelog/db.changelog-master.xml + enabled: false + + cache: + type: redis + data: + redis: + host: localhost + port: 6379 + timeout: 2s + + jackson: + serialization: + INDENT_OUTPUT: true + + main: + allow-bean-definition-overriding: true + +management: + endpoints: + web: + exposure: + include: health,info,metrics,custom + base-path: /actuator + enabled-by-default: true + endpoint: + health: + show-details: always + show-components: always + enabled: true + info: + enabled: true + metrics: + enabled: true + info: + env: + enabled: true + build: + enabled: true + health: + db: + enabled: true + diskspace: + enabled: true + +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + display-request-duration: true + operationsSorter: method + +logging: + level: + root: INFO + org.springframework.web: INFO + com.example.todo: DEBUG + +--- +spring: + config: + activate: + on-profile: test + + + liquibase: + change-log: classpath:db/changelog/db.changelog-master.xml + + cache: + type: none + +logging: + level: + root: INFO diff --git a/src/main/resources/hibernate.cfg.xml b/src/main/resources/hibernate.cfg.xml new file mode 100644 index 00000000..aa31892b --- /dev/null +++ b/src/main/resources/hibernate.cfg.xml @@ -0,0 +1,22 @@ + + + + + + + org.postgresql.Driver + jdbc:postgresql://localhost:5432/tododb + postgres + postgres + 10 + org.hibernate.dialect.PostgreSQLDialect + update + true + true + 20 + true + true + + + + \ 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/api/ToDoControllerIntegrationTest.java b/src/test/java/com/emobile/springtodo/api/ToDoControllerIntegrationTest.java new file mode 100644 index 00000000..6e1f071e --- /dev/null +++ b/src/test/java/com/emobile/springtodo/api/ToDoControllerIntegrationTest.java @@ -0,0 +1,173 @@ +//package com.emobile.springtodo.api; +// +//import com.emobile.springtodo.dto.ToDoDto; +//import com.emobile.springtodo.service.ToDoService; +//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.test.mock.mockito.MockBean; +//import org.springframework.http.MediaType; +//import org.springframework.test.web.servlet.MockMvc; +//import org.springframework.test.web.servlet.MvcResult; +//import org.testcontainers.junit.jupiter.Testcontainers; +// +//import static org.mockito.ArgumentMatchers.any; +//import static org.mockito.ArgumentMatchers.eq; +//import static org.mockito.Mockito.doNothing; +//import static org.mockito.Mockito.times; +//import static org.mockito.Mockito.verify; +//import static org.mockito.Mockito.when; +//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +//import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +// +//@SpringBootTest +//@AutoConfigureMockMvc +//@Testcontainers +// +//class ToDoControllerIntegrationTest { +// +// @Autowired +// private MockMvc mockMvc; +// @MockBean +// private ToDoService toDoService; +// +// @Test +// @DisplayName("Проверяет контроллер на получения объекта по id!") +// void testGetById() throws Exception { +// // Подготовка мок-объекта +// ToDoDto todo = new ToDoDto(); +// todo.setId(1L); +// todo.setDescription("Test description"); +// todo.setCompleted(false); +// +// when(toDoService.getById(1L)).thenReturn(todo); +// +// // Выполнение запроса +// MvcResult result = mockMvc.perform(get("/api/todos/1") +// .accept(MediaType.APPLICATION_JSON)) +// .andExpect(status().isOk()) +// .andReturn(); +// +// // Вывод тела ответа для отладки +// System.out.println(result.getResponse().getContentAsString()); +// +// // Проверка JSON с помощью JSONAssert +// String expectedJson = """ +// { +// "id": 1, +// "title": null, +// "description": "Test description", +// "completed": false +// } +// """; +// +// JSONAssert.assertEquals(expectedJson, result.getResponse().getContentAsString(), true); +// } +// +// +// +// @Test +// @DisplayName("Проверяет контроллер на создание задачи!") +// void testCreate() throws Exception { +// ToDoDto inputDto = new ToDoDto(); +// inputDto.setTitle("New Todo"); +// inputDto.setDescription("New description"); +// inputDto.setCompleted(false); +// +// ToDoDto createdDto = new ToDoDto(); +// createdDto.setId(1L); +// createdDto.setTitle("New Todo"); +// createdDto.setDescription("New description"); +// createdDto.setCompleted(false); +// +// when(toDoService.create(any(ToDoDto.class))).thenReturn(createdDto); +// +// String requestJson = """ +// { +// "title": "New Todo", +// "description": "New description", +// "completed": false +// } +// """; +// +// String expectedJson = """ +// { +// "id": 1, +// "title": "New Todo", +// "description": "New description", +// "completed": false +// } +// """; +// +// MvcResult result = mockMvc.perform(post("/api/todos") +// .contentType(MediaType.APPLICATION_JSON) +// .content(requestJson) +// .accept(MediaType.APPLICATION_JSON)) +// .andExpect(status().isOk()) // Если хочешь 201, настрой контроллер +// .andReturn(); +// +// JSONAssert.assertEquals(expectedJson, result.getResponse().getContentAsString(), true); +// } +// +// @Test +// @DisplayName("Проверяет контроллер на изменение задачи по id!") +// void testUpdate() throws Exception { +// ToDoDto inputDto = new ToDoDto(); +// inputDto.setTitle("Updated Todo"); +// inputDto.setDescription("Updated description"); +// inputDto.setCompleted(true); +// +// ToDoDto updatedDto = new ToDoDto(); +// updatedDto.setId(1L); +// updatedDto.setTitle("Updated Todo"); +// updatedDto.setDescription("Updated description"); +// updatedDto.setCompleted(true); +// +// when(toDoService.update(eq(1L), any(ToDoDto.class))).thenReturn(updatedDto); +// +// String requestJson = """ +// { +// "title": "Updated Todo", +// "description": "Updated description", +// "completed": true +// } +// """; +// +// String expectedJson = """ +// { +// "id": 1, +// "title": "Updated Todo", +// "description": "Updated description", +// "completed": true +// } +// """; +// +// MvcResult result = mockMvc.perform(put("/api/todos/1") +// .contentType(MediaType.APPLICATION_JSON) +// .content(requestJson) +// .accept(MediaType.APPLICATION_JSON)) +// .andExpect(status().isOk()) +// .andReturn(); +// +// JSONAssert.assertEquals(expectedJson, result.getResponse().getContentAsString(), true); +// } +// +// // ---------------------- delete ---------------------- +// @DisplayName("Проверяет контроллер на удаление задачи по id!") +// @Test +// void testDelete() throws Exception { +// // Просто проверяем, что сервис вызван и статус 204 +// doNothing().when(toDoService).delete(1L); +// +// mockMvc.perform(delete("/api/todos/1")) +// .andExpect(status().isOk());; +// +// verify(toDoService, times(1)).delete(1L); +// } +//} \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/emobile/springtodo/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 00000000..16474d54 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,51 @@ +//package com.emobile.springtodo.exception; +// +//import com.emobile.springtodo.service.ToDoService; +//import org.junit.jupiter.api.DisplayName; +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +//import org.springframework.boot.test.context.SpringBootTest; +//import org.springframework.boot.test.mock.mockito.MockBean; +//import org.springframework.test.web.servlet.MockMvc; +// +//import static org.mockito.BDDMockito.given; +//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +//import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +//import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +// +//@SpringBootTest +//@AutoConfigureMockMvc +//class GlobalExceptionHandlerTest { +// +// @Autowired +// private MockMvc mockMvc; +// +// @MockBean +// private ToDoService toDoService; +// +// @Test +// @DisplayName("Проверка на TaskNotFoundException!") +// void whenTaskNotFound_thenReturns404() throws Exception { +// given(toDoService.getById(9999L)) +// .willThrow(new TaskNotFoundException("Task with id 9999 not found")); +// +// mockMvc.perform(get("/api/todos/9999")) +// .andExpect(status().isNotFound()) +// .andExpect(jsonPath("$.error").value("Task Not Found")); +// } +// +// +// +// @Test +// @DisplayName("Проверка на общий Exception!") +// void whenUnexpectedException_thenReturns500() throws Exception { +// given(toDoService.getById(1L)) +// .willThrow(new RuntimeException("Unexpected error")); +// +// mockMvc.perform(get("/api/todos/1")) +// .andExpect(status().isInternalServerError()) +// .andExpect(jsonPath("$.error").value("Internal Server Error")) +// .andExpect(jsonPath("$.message").value("Unexpected error")); +// } +//} \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/repository/ToDoRepositoryTest.java b/src/test/java/com/emobile/springtodo/repository/ToDoRepositoryTest.java new file mode 100644 index 00000000..aad04846 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/repository/ToDoRepositoryTest.java @@ -0,0 +1,71 @@ +//package com.emobile.springtodo.repository; +// +//import com.emobile.springtodo.model.ToDo; +//import org.junit.jupiter.api.BeforeEach; +//import org.junit.jupiter.api.DisplayName; +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +//import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +//import org.springframework.jdbc.core.JdbcTemplate; +//import org.springframework.test.context.jdbc.Sql; +// +//import static org.assertj.core.api.Assertions.assertThat; +// +//@DataJpaTest +//@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +//@Sql(statements = { +// "DROP TABLE IF EXISTS todo;", +// "CREATE TABLE todo (" + +// "id BIGSERIAL PRIMARY KEY," + +// "title VARCHAR(255) NOT NULL," + +// "description TEXT," + +// "completed BOOLEAN DEFAULT FALSE," + +// "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP," + +// "updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP" + +// ");" +//}) +// +//class ToDoRepositoryTest { +// +// @Autowired +// private JdbcTemplate jdbcTemplate; +// +// private ToDoRepository repository; +// +// @BeforeEach +// void setUp() { +// repository = new ToDoRepository(jdbcTemplate); +// } +// +// @Test +// @DisplayName("Тестирует создание и поиск объекта по id!") +// void testCreateAndFindById() { +// ToDo todo = new ToDo(); +// todo.setTitle("Test Task"); +// todo.setDescription("Description"); +// todo.setCompleted(false); +// +// ToDo created = repository.create(todo); +// assertThat(created.getId()).isNotNull(); +// assertThat(created.getTitle()).isEqualTo("Test Task"); +// +// ToDo found = repository.findById(created.getId()); +// assertThat(found.getTitle()).isEqualTo("Test Task"); +// assertThat(found.getCompleted()).isFalse(); +// } +// +// +// @Test +// @DisplayName("Тестирует обновление задачи в по id!") +// void testUpdate() { +// ToDo todo = repository.create(new ToDo(null, "Old Title", "Old Desc", false, null, null)); +// todo.setTitle("New Title"); +// todo.setCompleted(true); +// +// ToDo updated = repository.update(todo.getId(), todo); +// assertThat(updated.getTitle()).isEqualTo("New Title"); +// assertThat(updated.getCompleted()).isTrue(); +// } +// +//} \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/service/ToDoServiceImplTest.java b/src/test/java/com/emobile/springtodo/service/ToDoServiceImplTest.java new file mode 100644 index 00000000..fe133c83 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/service/ToDoServiceImplTest.java @@ -0,0 +1,132 @@ +//package com.emobile.springtodo.service; +// +// +//import com.emobile.springtodo.dto.ToDoDto; +//import com.emobile.springtodo.mapper.ToDoMapper; +//import com.emobile.springtodo.metrics.TaskMetrics; +//import com.emobile.springtodo.model.ToDo; +//import com.emobile.springtodo.repository.ToDoRepository; +//import org.junit.jupiter.api.BeforeEach; +//import org.junit.jupiter.api.DisplayName; +//import org.junit.jupiter.api.Test; +//import org.mapstruct.factory.Mappers; +//import org.mockito.ArgumentMatchers; +//import org.mockito.Mock; +//import org.mockito.MockitoAnnotations; +//import org.springframework.jdbc.core.JdbcTemplate; +//import org.springframework.jdbc.core.RowMapper; +// +//import java.time.LocalDateTime; +//import java.util.List; +// +//import static org.assertj.core.api.Assertions.assertThat; +//import static org.mockito.ArgumentMatchers.anyInt; +//import static org.mockito.ArgumentMatchers.eq; +//import static org.mockito.Mockito.any; +//import static org.mockito.Mockito.anyLong; +//import static org.mockito.Mockito.anyString; +//import static org.mockito.Mockito.doNothing; +//import static org.mockito.Mockito.mock; +//import static org.mockito.Mockito.times; +//import static org.mockito.Mockito.verify; +//import static org.mockito.Mockito.when; +// +//@DisplayName("Unit-тесты для ToDoServiceImpl") +//class ToDoServiceImplTest { +// +// private ToDoRepository toDoRepository; +// private ToDoMapper mapper; +// private ToDoServiceImpl service; +// +// @Mock +// private TaskMetrics taskMetrics; +// +// @BeforeEach +// void setUp() { +// toDoRepository = mock(ToDoRepository.class); +// mapper = Mappers.getMapper(ToDoMapper.class); +// service = new ToDoServiceImpl(taskMetrics, toDoRepository, mapper); +// MockitoAnnotations.openMocks(this); +// } +// +// @Test +// @DisplayName("Создание новой задачи!") +// void create_ShouldReturnDtoWithId() { +// +// ToDoDto inputDto = new ToDoDto(); +// inputDto.setTitle("Test Task"); +// inputDto.setDescription("Test description"); +// inputDto.setCompleted(false); +// +// ToDo entityFromDb = new ToDo( +// 1L, +// inputDto.getTitle(), +// inputDto.getDescription(), +// inputDto.getCompleted(), +// LocalDateTime.now(), +// LocalDateTime.now() +// ); +// +// when(toDoRepository.create(any(ToDo.class))).thenReturn(entityFromDb); +// +// ToDoDto result = service.create(inputDto); +// +// assertThat(result).isNotNull(); +// assertThat(result.getId()).isEqualTo(1L); +// assertThat(result.getTitle()).isEqualTo("Test Task"); +// assertThat(result.getDescription()).isEqualTo("Test description"); +// assertThat(result.getCompleted()).isFalse(); +// +// verify(toDoRepository, times(1)).create(any(ToDo.class)); +// } +// +// +// +// +// +// @Test +// @DisplayName("Метод getById должен вернуть DTO по id!") +// void testGetById() { +// ToDo entity = new ToDo(1L, "Task", "Desc", true, LocalDateTime.now(), LocalDateTime.now()); +// when(toDoRepository.findById(1L)).thenReturn(entity); +// +// ToDoDto result = service.getById(1L); +// +// assertThat(result).isNotNull(); +// assertThat(result.getId()).isEqualTo(1L); +// assertThat(result.getCompleted()).isTrue(); +// +// verify(toDoRepository, times(1)).findById(1L); +// } +// +// +// @Test +// @DisplayName("Метод update должен вернуть обновленный DTO!") +// void testUpdate() { +// Long id = 1L; +// ToDoDto dto = new ToDoDto(null, "Updated Task", "Updated Desc", true); +// ToDo updated = new ToDo(id, "Updated Task", "Updated Desc", true, LocalDateTime.now(), LocalDateTime.now()); +// +// when(toDoRepository.update(eq(id), any(ToDo.class))).thenReturn(updated); +// +// ToDoDto result = service.update(id, dto); +// +// assertThat(result.getTitle()).isEqualTo("Updated Task"); +// assertThat(result.getCompleted()).isTrue(); +// +// verify(toDoRepository, times(1)).update(eq(id), any(ToDo.class)); +// } +// +// +// @Test +// @DisplayName("Метод delete должен удалить задачу!") +// void testDelete() { +// Long id = 1L; +// doNothing().when(toDoRepository).delete(id); +// +// service.delete(id); +// +// verify(toDoRepository, times(1)).delete(id); +// } +// +//} \ No newline at end of file