diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..235417e2
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,5 @@
+.idea
+.git
+.env
+.gitignore
+README.md
diff --git a/.env b/.env
new file mode 100644
index 00000000..9b455669
--- /dev/null
+++ b/.env
@@ -0,0 +1,12 @@
+POSTGRES_DB=todo_db
+POSTGRES_USER=user
+POSTGRES_PASSWORD=user
+JWT_SECRET=mySuperSecretKeyThatIsAtLeast32CharactersLong123!
+
+SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/todo_db
+SPRING_DATASOURCE_USERNAME=user
+SPRING_DATASOURCE_PASSWORD=user
+SPRING_DATA_REDIS_HOST=redis
+SPRING_DATA_REDIS_PORT=6379
+
+
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 00000000..af6d175a
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,31 @@
+image: maven:3.9-eclipse-temurin-21
+
+stages:
+ - test
+ - publish
+
+variables:
+ DOCKER_IMAGE: $DOCKERHUB_USERNAME/spring-todo
+
+test-on-dev-mr:
+ stage: test
+ script:
+ - mvn verify -B
+ rules:
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "dev"'
+
+publish-on-master:
+ stage: publish
+ image: docker:20.10.16
+ services:
+ - docker:20.10.16-dind
+ before_script:
+ - docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
+ script:
+ - mvn clean package -DskipTests -B
+ - docker build -t $DOCKER_IMAGE:$CI_COMMIT_SHORT_SHA .
+ - docker tag $DOCKER_IMAGE:$CI_COMMIT_SHORT_SHA $DOCKER_IMAGE:latest
+ - docker push $DOCKER_IMAGE:$CI_COMMIT_SHORT_SHA
+ - docker push $DOCKER_IMAGE:latest
+ rules:
+ - if: '$CI_COMMIT_BRANCH == "master"'
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..dbf330ed
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,4 @@
+FROM eclipse-temurin:21-jdk-alpine
+WORKDIR /app
+COPY target/*.jar app.jar
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 00000000..35d22c00
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,68 @@
+services:
+ postgres:
+ image: postgres:15
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB}
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ ports:
+ - "5433:5432"
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - todo-network
+
+ redis:
+ image: redis:7.4.5-alpine
+ restart: unless-stopped
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis-data:/data
+ networks:
+ - todo-network
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ ports:
+ - "8080:8080"
+ env_file:
+ - .env
+ environment:
+ SPRING_PROFILES_ACTIVE: docker
+ SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
+ SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
+ SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
+ SPRING_DATA_REDIS_HOST: redis
+ SPRING_DATA_REDIS_PORT: 6379
+ networks:
+ - todo-network
+ volumes:
+ - ./logs:/app/logs
+ restart: unless-stopped
+
+networks:
+ todo-network:
+ driver: bridge
+
+volumes:
+ postgres-data:
+ redis-data:
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 03bc446d..9adf0caf 100644
--- a/pom.xml
+++ b/pom.xml
@@ -2,12 +2,14 @@
4.0.0
+
org.springframework.boot
spring-boot-starter-parent
- 3.3.5
-
+ 3.5.5
+
+
com.emobile
SpringToDo
0.0.1-SNAPSHOT
@@ -16,12 +18,71 @@
21
+ 0.12.6
org.springframework.boot
- spring-boot-starter
+ spring-boot-starter-security
+
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+ org.liquibase
+ liquibase-core
+
+
+
+ org.hibernate.validator
+ hibernate-validator
+ 8.0.1.Final
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ 2.7.0
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ ${jjwt.version}
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ ${jjwt.version}
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ ${jjwt.version}
+ runtime
+
+
+
+ org.projectlombok
+ lombok
+ true
@@ -29,15 +90,102 @@
spring-boot-starter-test
test
+
+ org.testcontainers
+ testcontainers
+ test
+
+
+ org.testcontainers
+ postgresql
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+ org.skyscreamer
+ jsonassert
+ 1.5.1
+ test
+
+
+ org.springframework.security
+ spring-security-test
+ test
+
+
+
+ org.mapstruct
+ mapstruct
+ 1.5.5.Final
+
+
+ org.mapstruct
+ mapstruct-processor
+ 1.5.5.Final
+ provided
+
+
+
+ org.springframework.boot
+ spring-boot-starter-cache
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+
+ io.github.cdimascio
+ dotenv-java
+ 3.0.0
+ test
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+ 21
+ 21
+
+
+ org.mapstruct
+ mapstruct-processor
+ 1.5.5.Final
+
+
+ org.projectlombok
+ lombok
+ 1.18.30
+
+
+ org.projectlombok
+ lombok-mapstruct-binding
+ 0.2.0
+
+
+
+
org.springframework.boot
spring-boot-maven-plugin
+ 3.5.5
+
+
+
+ repackage
+
+
+
-
-
+
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/config/AppInfoContributor.java b/src/main/java/com/emobile/springtodo/config/AppInfoContributor.java
new file mode 100644
index 00000000..0e6efca6
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/config/AppInfoContributor.java
@@ -0,0 +1,26 @@
+package com.emobile.springtodo.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.actuate.info.Info;
+import org.springframework.boot.actuate.info.InfoContributor;
+import org.springframework.stereotype.Component;
+
+import java.time.Instant;
+import java.util.Map;
+
+@Component
+public class AppInfoContributor implements InfoContributor {
+
+ @Value("${spring.application.name}")
+ private String appName;
+
+ @Override
+ public void contribute(Info.Builder builder) {
+ builder.withDetail("application", Map.of(
+ "name", appName,
+ "description", "Todo Management Application",
+ "version", "1.0.0",
+ "startupTime", Instant.now().toString()
+ ));
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/config/JwtConfig.java b/src/main/java/com/emobile/springtodo/config/JwtConfig.java
new file mode 100644
index 00000000..05c74d17
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/config/JwtConfig.java
@@ -0,0 +1,38 @@
+package com.emobile.springtodo.config;
+
+import io.jsonwebtoken.security.Keys;
+import jakarta.annotation.PostConstruct;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+
+@Getter
+@Setter
+@Configuration
+@ConfigurationProperties(prefix = "jwt")
+public class JwtConfig {
+
+ private String secret;
+ private long expirationMs;
+ private SecretKey secretKey;
+
+ @PostConstruct
+ public void init() {
+ if (secret == null || secret.trim().isEmpty()) {
+ throw new IllegalStateException("JWT secret is not configured. Please set 'jwt.secret' property or JWT_SECRET environment variable.");
+ }
+ if (secret.length() < 32) {
+ throw new IllegalStateException(
+ "JWT secret is too short. Must be at least 32 characters long for HS256 algorithm. " +
+ "Current length: " + secret.length() + " characters. " +
+ "Please set a longer secret in JWT_SECRET environment variable or application.yml"
+ );
+ }
+
+ this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/config/OpenApiConfig.java b/src/main/java/com/emobile/springtodo/config/OpenApiConfig.java
new file mode 100644
index 00000000..ec03ba88
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/config/OpenApiConfig.java
@@ -0,0 +1,32 @@
+package com.emobile.springtodo.config;
+
+import io.swagger.v3.oas.annotations.OpenAPIDefinition;
+import io.swagger.v3.oas.annotations.info.Info;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@OpenAPIDefinition(
+ info = @Info(title = "TODO приложение", version = "1.0", description = """
+ Приложение для сохранения и контролем
+ выполнения задач"""),
+ security = @SecurityRequirement(name = "bearerAuth")
+)
+public class OpenApiConfig {
+
+ @Bean
+ public OpenAPI openAPI() {
+ return new OpenAPI()
+ .addSecurityItem(new io.swagger.v3.oas.models.security.SecurityRequirement()
+ .addList("bearerAuth"))
+ .components(new Components()
+ .addSecuritySchemes("bearerAuth", new SecurityScheme()
+ .type(SecurityScheme.Type.HTTP)
+ .scheme("bearer")
+ .bearerFormat("JWT")));
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/config/RedisConfig.java b/src/main/java/com/emobile/springtodo/config/RedisConfig.java
new file mode 100644
index 00000000..915a1f40
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/config/RedisConfig.java
@@ -0,0 +1,34 @@
+package com.emobile.springtodo.config;
+
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.data.redis.cache.RedisCacheConfiguration;
+import org.springframework.data.redis.cache.RedisCacheManager;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.RedisSerializationContext;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+import java.time.Duration;
+
+@Configuration
+@EnableCaching
+@Profile("!test")
+public class RedisConfig {
+
+ @Bean
+ public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
+ RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
+ .entryTtl(Duration.ofMinutes(30))
+ .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
+ .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
+ .disableCachingNullValues();
+
+ return RedisCacheManager.builder(redisConnectionFactory)
+ .cacheDefaults(config)
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/config/SecurityConfig.java b/src/main/java/com/emobile/springtodo/config/SecurityConfig.java
new file mode 100644
index 00000000..8f035c29
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/config/SecurityConfig.java
@@ -0,0 +1,53 @@
+package com.emobile.springtodo.config;
+
+
+import com.emobile.springtodo.security.JwtAuthFilter;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+
+@Configuration
+@EnableWebSecurity
+@EnableMethodSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception {
+ return http
+ .csrf(AbstractHttpConfigurer::disable)
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/auth/**", "/swagger-ui/**", "/v3/api-docs/**","/actuator/health",
+ "/actuator/info","/actuator/metrics","/actuator/metrics/**").permitAll()
+ .anyRequest().authenticated()
+ )
+ .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
+ .build();
+ }
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Bean
+ public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
+ return config.getAuthenticationManager();
+ }
+
+
+}
diff --git a/src/main/java/com/emobile/springtodo/controller/AuthController.java b/src/main/java/com/emobile/springtodo/controller/AuthController.java
new file mode 100644
index 00000000..a38007d1
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/controller/AuthController.java
@@ -0,0 +1,49 @@
+package com.emobile.springtodo.controller;
+
+import com.emobile.springtodo.controller.openApi.AuthApi;
+import com.emobile.springtodo.dto.AuthRequest;
+import com.emobile.springtodo.dto.AuthResponse;
+import com.emobile.springtodo.dto.UserDto;
+import com.emobile.springtodo.security.JwtService;
+import com.emobile.springtodo.service.UserService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/auth")
+@RequiredArgsConstructor
+public class AuthController implements AuthApi {
+
+ private final AuthenticationManager authenticationManager;
+ private final JwtService jwtService;
+ private final UserService userService;
+
+ @Override
+ @PostMapping("/login")
+ public AuthResponse login(@Valid @RequestBody AuthRequest request) {
+ Authentication authentication = authenticationManager.authenticate(
+ new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
+ );
+
+ UserDetails userDetails = (UserDetails) authentication.getPrincipal();
+ String token = jwtService.generateToken(userDetails);
+
+ Long expiresIn = jwtService.getExpirationTime() / 1000;
+
+ return new AuthResponse(token, "Bearer", expiresIn);
+ }
+
+ @Override
+ @PostMapping("/register")
+ public UserDto register(@Valid @RequestBody AuthRequest req) {
+ return userService.save(req);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/controller/TodoController.java b/src/main/java/com/emobile/springtodo/controller/TodoController.java
new file mode 100644
index 00000000..b4f54e68
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/controller/TodoController.java
@@ -0,0 +1,83 @@
+package com.emobile.springtodo.controller;
+
+import com.emobile.springtodo.controller.openApi.TodoApi;
+import com.emobile.springtodo.dto.TodoCreateDto;
+import com.emobile.springtodo.dto.TodoDto;
+import com.emobile.springtodo.dto.PageResponse;
+import com.emobile.springtodo.enums.Status;
+import com.emobile.springtodo.service.TodoService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.web.bind.annotation.*;
+
+@RequestMapping("/todos")
+@RequiredArgsConstructor
+@RestController
+public class TodoController implements TodoApi {
+
+ private final TodoService service;
+
+ @Override
+ @PreAuthorize("hasRole('ADMIN')")
+ @PostMapping
+ public TodoDto createTodo(@Valid @RequestBody TodoCreateDto dto) {
+ return service.add(dto);
+ }
+
+ @Override
+ @PreAuthorize("hasRole('ADMIN')")
+ @GetMapping
+ public PageResponse getAllTodos(@RequestParam(defaultValue = "10") int limit,
+ @RequestParam(defaultValue = "0") int offset) {
+ return service.getAll(limit, offset);
+ }
+
+ @Override
+ @GetMapping("/current-user")
+ public PageResponse getCurrentUserTodos(@AuthenticationPrincipal UserDetails userDetails,
+ @RequestParam(defaultValue = "10") int limit,
+ @RequestParam(defaultValue = "0") int offset) {
+ return service.getTodoByUserName(userDetails.getUsername(), limit, offset);
+ }
+
+ @Override
+ @PostMapping("/current-user")
+ public TodoDto createTodoForCurrentUser(@Valid @RequestBody TodoCreateDto dto,
+ @AuthenticationPrincipal UserDetails userDetails) {
+ TodoDto createdTodo = service.add(dto);
+ service.addTodoUser(createdTodo.getTitle(), userDetails.getUsername());
+ return createdTodo;
+ }
+
+ @Override
+ @PreAuthorize("hasRole('ADMIN')")
+ @PostMapping("/{todoId}/assign/{username}")
+ public void assignTodoToUser(@PathVariable String todoId, @PathVariable String username) {
+ service.addTodoUser(todoId, username);
+ }
+
+ @Override
+ @DeleteMapping("/current-user/{todoName}")
+ public boolean removeTodoFromCurrentUser(@AuthenticationPrincipal UserDetails userDetails,
+ @PathVariable String todoName) {
+ return service.removeTodoFromUser(userDetails.getUsername(), todoName);
+ }
+
+ @Override
+ @PatchMapping("/current-user/{title}/status")
+ public boolean updateTodoStatus(@AuthenticationPrincipal UserDetails userDetails,
+ @RequestParam Status status,
+ @PathVariable String title) {
+ return service.updateStatusTodoCurrentUser(userDetails.getUsername(), status, title);
+ }
+
+ @Override
+ @PreAuthorize("hasRole('ADMIN')")
+ @DeleteMapping("/{id}")
+ public void deleteTodo(@PathVariable Long id) {
+ service.delete(id);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/controller/UserController.java b/src/main/java/com/emobile/springtodo/controller/UserController.java
new file mode 100644
index 00000000..7d681628
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/controller/UserController.java
@@ -0,0 +1,42 @@
+package com.emobile.springtodo.controller;
+
+import com.emobile.springtodo.controller.openApi.UserApi;
+import com.emobile.springtodo.model.User;
+import com.emobile.springtodo.service.UserService;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RequestMapping("/users")
+@RequiredArgsConstructor
+@RestController
+public class UserController implements UserApi {
+
+ private final UserService service;
+
+ @Override
+ @PreAuthorize("hasRole('ADMIN')")
+ @GetMapping
+ public List getAllUsers() {
+ return service.getAllUsers();
+ }
+
+ @Override
+ @DeleteMapping
+ public void deleteUser(@AuthenticationPrincipal UserDetails userDetails) {
+ service.deleteUser(userDetails.getUsername());
+ }
+
+ @Override
+ @PreAuthorize("hasRole('ADMIN')")
+ @DeleteMapping("/{id}")
+ public void deleteUserById(@PathVariable Long id) {
+ service.deleteUserById(id);
+ }
+
+}
diff --git a/src/main/java/com/emobile/springtodo/controller/openApi/AuthApi.java b/src/main/java/com/emobile/springtodo/controller/openApi/AuthApi.java
new file mode 100644
index 00000000..4b12a9e1
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/controller/openApi/AuthApi.java
@@ -0,0 +1,46 @@
+package com.emobile.springtodo.controller.openApi;
+
+import com.emobile.springtodo.dto.AuthRequest;
+import com.emobile.springtodo.dto.AuthResponse;
+import com.emobile.springtodo.dto.UserDto;
+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.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+
+import java.util.Map;
+
+@Tag(name = "Authentication", description = "API для аутентификации и регистрации пользователей")
+public interface AuthApi {
+
+ @Operation(summary = "Вход в систему", description = "Аутентификация пользователя и получение JWT токена")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Успешная аутентификация",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class))),
+ @ApiResponse(responseCode = "401", description = "Неверные учетные данные",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class))),
+ @ApiResponse(responseCode = "400", description = "Неверный формат запроса",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class)))
+ })
+ @PostMapping("/login")
+ AuthResponse login(@RequestBody AuthRequest request);
+
+ @Operation(summary = "Регистрация пользователя", description = "Создание нового пользователя")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Пользователь успешно создан",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = UserDto.class))),
+ @ApiResponse(responseCode = "400", description = "Пользователь уже существует или неверный формат",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = AuthResponse.class)))
+ })
+ @PostMapping("/register")
+ UserDto register(@RequestBody AuthRequest request);
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/controller/openApi/TodoApi.java b/src/main/java/com/emobile/springtodo/controller/openApi/TodoApi.java
new file mode 100644
index 00000000..839cb5a1
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/controller/openApi/TodoApi.java
@@ -0,0 +1,147 @@
+package com.emobile.springtodo.controller.openApi;
+
+import com.emobile.springtodo.dto.TodoCreateDto;
+import com.emobile.springtodo.dto.TodoDto;
+import com.emobile.springtodo.dto.PageResponse;
+import com.emobile.springtodo.enums.Status;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+@Tag(name = "Todos", description = "API для управления задачами")
+@SecurityRequirement(name = "bearerAuth")
+public interface TodoApi {
+
+ @Operation(summary = "Создать задачу", description = "Создание новой задачи (только для ADMIN)")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Задача успешно создана",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = TodoDto.class))),
+ @ApiResponse(responseCode = "403", description = "Доступ запрещен",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class))),
+ @ApiResponse(responseCode = "400", description = "Неверный формат запроса",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class)))
+ })
+ @PostMapping
+ TodoDto createTodo(@RequestBody TodoCreateDto dto);
+
+ @Operation(summary = "Получить все задачи", description = "Получение списка всех задач с пагинацией (только для ADMIN)")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Список задач успешно получен",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = PageResponse.class))),
+ @ApiResponse(responseCode = "403", description = "Доступ запрещен",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class)))
+ })
+ @GetMapping
+ PageResponse getAllTodos(@Parameter(description = "Лимит записей") @RequestParam(defaultValue = "10") int limit,
+ @Parameter(description = "Смещение") @RequestParam(defaultValue = "0") int offset);
+
+ @Operation(summary = "Получить задачи текущего пользователя", description = "Получение задач текущего пользователя с пагинацией")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Список задач успешно получен",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = PageResponse.class))),
+ @ApiResponse(responseCode = "401", description = "Не авторизован",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class)))
+ })
+ @GetMapping("/current-user")
+ PageResponse getCurrentUserTodos(@AuthenticationPrincipal UserDetails userDetails,
+ @Parameter(description = "Лимит записей") @RequestParam(defaultValue = "10") int limit,
+ @Parameter(description = "Смещение") @RequestParam(defaultValue = "0") int offset);
+
+ @Operation(summary = "Создать задачу для текущего пользователя", description = "Создание задачи и привязка к текущему пользователю")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Задача успешно создана",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = TodoDto.class))),
+ @ApiResponse(responseCode = "401", description = "Не авторизован",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class))),
+ @ApiResponse(responseCode = "400", description = "Неверный формат запроса",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class)))
+ })
+ @PostMapping("/current-user")
+ TodoDto createTodoForCurrentUser(@RequestBody TodoCreateDto dto, @AuthenticationPrincipal UserDetails userDetails);
+
+ @Operation(summary = "Назначить задачу пользователю", description = "Привязка задачи к пользователю (только для ADMIN)")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Задача успешно назначена"),
+ @ApiResponse(responseCode = "403", description = "Доступ запрещен",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class))),
+ @ApiResponse(responseCode = "400", description = "Неверные параметры",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class))),
+ @ApiResponse(responseCode = "404", description = "Задача или пользователь не найдены",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class)))
+ })
+ @PostMapping("/{todoId}/assign/{username}")
+ void assignTodoToUser(@Parameter(description = "ID задачи") @PathVariable String todoId,
+ @Parameter(description = "Имя пользователя") @PathVariable String username);
+
+ @Operation(summary = "Удалить задачу текущего пользователя", description = "Удаление задачи из списка задач текущего пользователя")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Задача успешно удалена",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Boolean.class))),
+ @ApiResponse(responseCode = "401", description = "Не авторизован",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class))),
+ @ApiResponse(responseCode = "404", description = "Задача не найдена",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class)))
+ })
+ @DeleteMapping("/current-user/{todoName}")
+ boolean removeTodoFromCurrentUser(@AuthenticationPrincipal UserDetails userDetails,
+ @Parameter(description = "Название задачи") @PathVariable String todoName);
+
+ @Operation(summary = "Обновить статус задачи", description = "Обновление статуса задачи текущего пользователя")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Статус успешно обновлен",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Boolean.class))),
+ @ApiResponse(responseCode = "401", description = "Не авторизован",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class))),
+ @ApiResponse(responseCode = "400", description = "Неверные параметры",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class))),
+ @ApiResponse(responseCode = "404", description = "Задача не найдена",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class)))
+ })
+ @PatchMapping("/current-user/{title}/status")
+ boolean updateTodoStatus(@AuthenticationPrincipal UserDetails userDetails,
+ @Parameter(description = "Статус задачи") @RequestParam Status status,
+ @Parameter(description = "Название задачи") @PathVariable String title);
+
+ @Operation(summary = "Удалить задачу по ID", description = "Удаление задачи по ID (только для ADMIN)")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Задача успешно удалена"),
+ @ApiResponse(responseCode = "403", description = "Доступ запрещен",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class))),
+ @ApiResponse(responseCode = "404", description = "Задача не найдена",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class)))
+ })
+ @DeleteMapping("/{id}")
+ void deleteTodo(@Parameter(description = "ID задачи") @PathVariable Long id);
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/controller/openApi/UserApi.java b/src/main/java/com/emobile/springtodo/controller/openApi/UserApi.java
new file mode 100644
index 00000000..62cc4870
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/controller/openApi/UserApi.java
@@ -0,0 +1,60 @@
+package com.emobile.springtodo.controller.openApi;
+
+import com.emobile.springtodo.model.User;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "Users", description = "API для управления пользователями")
+@SecurityRequirement(name = "bearerAuth")
+public interface UserApi {
+
+ @Operation(summary = "Получить всех пользователей", description = "Получение списка всех пользователей (только для ADMIN)")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Список пользователей успешно получен",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = User.class))),
+ @ApiResponse(responseCode = "403", description = "Доступ запрещен",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class)))
+ })
+ @GetMapping
+ List getAllUsers();
+
+ @Operation(summary = "Удалить текущего пользователя", description = "Удаление текущего авторизованного пользователя")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Пользователь успешно удален"),
+ @ApiResponse(responseCode = "401", description = "Не авторизован",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class)))
+ })
+ @DeleteMapping
+ void deleteUser(@AuthenticationPrincipal UserDetails userDetails);
+
+ @Operation(summary = "Удалить пользователя по ID", description = "Удаление пользователя по ID (только для ADMIN)")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Пользователь успешно удален"),
+ @ApiResponse(responseCode = "403", description = "Доступ запрещен",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class))),
+ @ApiResponse(responseCode = "404", description = "Пользователь не найден",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = Map.class)))
+ })
+ @DeleteMapping("/{id}")
+ void deleteUserById(@Parameter(description = "ID пользователя") @PathVariable Long id);
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/dto/AuthRequest.java b/src/main/java/com/emobile/springtodo/dto/AuthRequest.java
new file mode 100644
index 00000000..3aaa0f68
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/dto/AuthRequest.java
@@ -0,0 +1,24 @@
+package com.emobile.springtodo.dto;
+
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+@Data
+@Schema(description = "Запрос для аутентификации или регистрации")
+@AllArgsConstructor
+public class AuthRequest {
+
+ @Schema(example = "admin")
+ @NotEmpty
+ @Size(min = 3, max = 20)
+ private String username;
+ @Schema(example = "qwerty12345")
+ @NotEmpty
+ @Size(min = 6, max = 20)
+ private String password;
+
+}
diff --git a/src/main/java/com/emobile/springtodo/dto/AuthResponse.java b/src/main/java/com/emobile/springtodo/dto/AuthResponse.java
new file mode 100644
index 00000000..8d3f0861
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/dto/AuthResponse.java
@@ -0,0 +1,24 @@
+package com.emobile.springtodo.dto;
+
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "Ответ аутентификации")
+public class AuthResponse {
+
+ @Schema(example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
+ private String token;
+
+ @Schema(example = "Bearer")
+ private String tokenType = "Bearer";
+
+ @Schema(example = "3600")
+ private Long expiresIn;
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/dto/PageResponse.java b/src/main/java/com/emobile/springtodo/dto/PageResponse.java
new file mode 100644
index 00000000..f35d18ed
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/dto/PageResponse.java
@@ -0,0 +1,23 @@
+package com.emobile.springtodo.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+@Builder
+@Data
+@AllArgsConstructor
+@Schema(description = "Ответ с пагинацией и информацией о страницах")
+public class PageResponse {
+ private List content;
+ private int total;
+ private int limit;
+ private int offset;
+ private int totalPages;
+ private int currentPage;
+ private boolean hasNext;
+ private boolean hasPrevious;
+}
diff --git a/src/main/java/com/emobile/springtodo/dto/TodoCreateDto.java b/src/main/java/com/emobile/springtodo/dto/TodoCreateDto.java
new file mode 100644
index 00000000..7bfafdaa
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/dto/TodoCreateDto.java
@@ -0,0 +1,22 @@
+package com.emobile.springtodo.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+
+@Data
+@Schema(description = "Объект задачи (Todo) создание")
+@AllArgsConstructor
+public class TodoCreateDto {
+
+ @Schema(example = "Изучить Spring Security")
+ @NotBlank
+ private String title;
+
+ @Schema(example = "Изучить основы аутентификации и авторизации")
+ @NotBlank
+ private String description;
+
+}
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..e74a80ca
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/dto/TodoDto.java
@@ -0,0 +1,23 @@
+package com.emobile.springtodo.dto;
+
+import com.emobile.springtodo.enums.Status;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+
+@Data
+@Builder
+@Schema(description = "Объект задачи (Todo) ответ")
+public class TodoDto {
+
+ @Schema(example = "Изучить Spring Security")
+ private String title;
+
+ @Schema(example = "Изучить основы аутентификации и авторизации")
+ private String description;
+
+ @Schema(example = "IN_PROGRESS")
+ private Status status;
+
+}
diff --git a/src/main/java/com/emobile/springtodo/dto/UserDto.java b/src/main/java/com/emobile/springtodo/dto/UserDto.java
new file mode 100644
index 00000000..eefa3b18
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/dto/UserDto.java
@@ -0,0 +1,13 @@
+package com.emobile.springtodo.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+
+@Data
+@AllArgsConstructor
+public class UserDto {
+
+ private String username;
+
+}
diff --git a/src/main/java/com/emobile/springtodo/enums/Role.java b/src/main/java/com/emobile/springtodo/enums/Role.java
new file mode 100644
index 00000000..add5e1ac
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/enums/Role.java
@@ -0,0 +1,5 @@
+package com.emobile.springtodo.enums;
+
+public enum Role {
+ USER, ADMIN
+}
diff --git a/src/main/java/com/emobile/springtodo/enums/Status.java b/src/main/java/com/emobile/springtodo/enums/Status.java
new file mode 100644
index 00000000..d79c12b8
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/enums/Status.java
@@ -0,0 +1,5 @@
+package com.emobile.springtodo.enums;
+
+public enum Status {
+ CREATED,IN_PROGRESS,COMPLETED,FAILED
+}
diff --git a/src/main/java/com/emobile/springtodo/exception/AppError.java b/src/main/java/com/emobile/springtodo/exception/AppError.java
new file mode 100644
index 00000000..bddc9e06
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/AppError.java
@@ -0,0 +1,8 @@
+package com.emobile.springtodo.exception;
+
+
+public record AppError(int status, String message, Object details) {
+ public AppError(int status, String message) {
+ this(status, 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..a8fcfc6b
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java
@@ -0,0 +1,74 @@
+package com.emobile.springtodo.exception;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.List;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(UserNotFoundException.class)
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ public AppError handleUserNotFound(UserNotFoundException e) {
+ return new AppError(HttpStatus.NOT_FOUND.value(), e.getMessage());
+ }
+
+ @ExceptionHandler(UserIsPresentException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public AppError handleUserIsPresent(UserIsPresentException e) {
+ return new AppError(HttpStatus.BAD_REQUEST.value(), e.getMessage());
+ }
+
+ @ExceptionHandler(TodoIsPresentException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public AppError handleTodoIsPresent(TodoIsPresentException e) {
+ return new AppError(HttpStatus.BAD_REQUEST.value(), e.getMessage());
+ }
+
+ @ExceptionHandler(TodoNotFoundException.class)
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ public AppError handleTodoNotFound(TodoNotFoundException e) {
+ return new AppError(HttpStatus.NOT_FOUND.value(), e.getMessage());
+ }
+
+ @ExceptionHandler(UnauthorizedActionException.class)
+ @ResponseStatus(HttpStatus.UNAUTHORIZED)
+ public AppError handleUnauthorized(UnauthorizedActionException ex) {
+ return new AppError(HttpStatus.UNAUTHORIZED.value(), ex.getMessage());
+ }
+
+ @ExceptionHandler(AuthenticationException.class)
+ @ResponseStatus(HttpStatus.UNAUTHORIZED)
+ public AppError handleUnauthorized(AuthenticationException ex) {
+ return new AppError(HttpStatus.UNAUTHORIZED.value(), ex.getMessage());
+ }
+
+ @ExceptionHandler(AccessDeniedException.class)
+ @ResponseStatus(HttpStatus.FORBIDDEN)
+ public AppError handleAccessDenied(AccessDeniedException ex) {
+ return new AppError(HttpStatus.FORBIDDEN.value(), "Access Denied");
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public AppError handleValidationException(MethodArgumentNotValidException ex) {
+ List errors = ex.getBindingResult()
+ .getFieldErrors()
+ .stream()
+ .map(error -> error.getField() + ": " + error.getDefaultMessage())
+ .toList();
+ return new AppError(HttpStatus.BAD_REQUEST.value(), "Validation failed", errors);
+ }
+
+ @ExceptionHandler(Exception.class)
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public AppError handleGeneralException(Exception e) {
+ return new AppError(HttpStatus.INTERNAL_SERVER_ERROR.value(), e.getMessage());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/exception/TodoIsPresentException.java b/src/main/java/com/emobile/springtodo/exception/TodoIsPresentException.java
new file mode 100644
index 00000000..cdbfbaaf
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/TodoIsPresentException.java
@@ -0,0 +1,7 @@
+package com.emobile.springtodo.exception;
+
+public class TodoIsPresentException extends RuntimeException{
+ public TodoIsPresentException(String title) {
+ super(title + " is already present");
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/exception/TodoNotFoundException.java b/src/main/java/com/emobile/springtodo/exception/TodoNotFoundException.java
new file mode 100644
index 00000000..03362b76
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/TodoNotFoundException.java
@@ -0,0 +1,7 @@
+package com.emobile.springtodo.exception;
+
+public class TodoNotFoundException extends RuntimeException{
+ public TodoNotFoundException(String title) {
+ super(title + " not found");
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/exception/UnauthorizedActionException.java b/src/main/java/com/emobile/springtodo/exception/UnauthorizedActionException.java
new file mode 100644
index 00000000..ac1ad277
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/UnauthorizedActionException.java
@@ -0,0 +1,7 @@
+package com.emobile.springtodo.exception;
+
+public class UnauthorizedActionException extends RuntimeException {
+ public UnauthorizedActionException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/exception/UserIsPresentException.java b/src/main/java/com/emobile/springtodo/exception/UserIsPresentException.java
new file mode 100644
index 00000000..264366f0
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/UserIsPresentException.java
@@ -0,0 +1,7 @@
+package com.emobile.springtodo.exception;
+
+public class UserIsPresentException extends RuntimeException{
+ public UserIsPresentException(String userName) {
+ super(userName + " user is present");
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/exception/UserNotFoundException.java b/src/main/java/com/emobile/springtodo/exception/UserNotFoundException.java
new file mode 100644
index 00000000..9137b3d2
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/exception/UserNotFoundException.java
@@ -0,0 +1,10 @@
+package com.emobile.springtodo.exception;
+
+public class UserNotFoundException extends RuntimeException {
+ public UserNotFoundException(Long id) {
+ super("User with ID: " + id + " not found");
+ }
+ public UserNotFoundException(String username) {
+ super("User '" + username + "' not found");
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/mapper/RowMappers.java b/src/main/java/com/emobile/springtodo/mapper/RowMappers.java
new file mode 100644
index 00000000..b52c9c7e
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/mapper/RowMappers.java
@@ -0,0 +1,32 @@
+package com.emobile.springtodo.mapper;
+
+
+import com.emobile.springtodo.enums.Role;
+import com.emobile.springtodo.enums.Status;
+import com.emobile.springtodo.model.Todo;
+import com.emobile.springtodo.model.User;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.stereotype.Component;
+
+
+@Component
+public class RowMappers {
+
+ public final RowMapper userRowMapper = (rs, rowNum) -> User.builder()
+ .id(rs.getLong("id"))
+ .username(rs.getString("username"))
+ .password(rs.getString("password"))
+ .build();
+
+ public final RowMapper roleRowMapper = (rs, rowNum) ->
+ Role.valueOf(rs.getString("role_name"));
+
+ public final RowMapper todoRowMapper = (rs, rowNum) -> Todo.builder()
+ .id(rs.getLong("id"))
+ .title(rs.getString("title"))
+ .description(rs.getString("description"))
+ .status(Status.valueOf(rs.getString("status")))
+ .build();
+
+
+}
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..38e4f4d2
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/mapper/TodoMapper.java
@@ -0,0 +1,14 @@
+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 todo);
+
+}
+
diff --git a/src/main/java/com/emobile/springtodo/mapper/UserMapper.java b/src/main/java/com/emobile/springtodo/mapper/UserMapper.java
new file mode 100644
index 00000000..c62061e2
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/mapper/UserMapper.java
@@ -0,0 +1,13 @@
+package com.emobile.springtodo.mapper;
+
+import com.emobile.springtodo.dto.UserDto;
+import com.emobile.springtodo.model.User;
+import org.mapstruct.Mapper;
+
+
+@Mapper(componentModel = "spring")
+public interface UserMapper {
+
+ UserDto toDto(User user);
+
+}
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..54d7ca8e
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/model/Todo.java
@@ -0,0 +1,19 @@
+package com.emobile.springtodo.model;
+
+import com.emobile.springtodo.enums.Status;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.Set;
+
+@Data
+@Builder
+public class Todo {
+
+ private long id;
+ private String title;
+ private String description;
+ private Set users;
+ private Status status;
+
+}
diff --git a/src/main/java/com/emobile/springtodo/model/User.java b/src/main/java/com/emobile/springtodo/model/User.java
new file mode 100644
index 00000000..544ad039
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/model/User.java
@@ -0,0 +1,21 @@
+package com.emobile.springtodo.model;
+
+import com.emobile.springtodo.enums.Role;
+import lombok.Builder;
+import lombok.Data;
+
+
+import java.util.List;
+import java.util.Set;
+
+@Data
+@Builder
+public class User {
+
+ private long id;
+ private String username;
+ private Set todos;
+ private String password;
+ private List roles;
+
+}
diff --git a/src/main/java/com/emobile/springtodo/repository/TodoRepository.java b/src/main/java/com/emobile/springtodo/repository/TodoRepository.java
new file mode 100644
index 00000000..f8400536
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/repository/TodoRepository.java
@@ -0,0 +1,33 @@
+package com.emobile.springtodo.repository;
+
+import com.emobile.springtodo.dto.TodoCreateDto;
+import com.emobile.springtodo.enums.Status;
+import com.emobile.springtodo.model.Todo;
+
+
+import java.util.List;
+import java.util.Optional;
+
+
+public interface TodoRepository {
+
+
+ List findAll(int limit, int offset);
+
+ List findTodosByUserName(String userName, int limit, int offset);
+
+ Optional create(TodoCreateDto dto);
+
+ void addTodoToUser(String todoTitle, String userName);
+
+ int deleteTodoFromUserByUsername(String username, String todoTitle);
+
+ void deleteTodoById(Long id);
+
+ int updateStatus(String username, Status status, String todoTitle);
+
+ int countAllTodos();
+
+ int countTodosByUserName(String userName);
+
+}
diff --git a/src/main/java/com/emobile/springtodo/repository/UserRepository.java b/src/main/java/com/emobile/springtodo/repository/UserRepository.java
new file mode 100644
index 00000000..bffe0077
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/repository/UserRepository.java
@@ -0,0 +1,24 @@
+package com.emobile.springtodo.repository;
+
+import com.emobile.springtodo.model.User;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface UserRepository {
+
+ List findAll();
+
+ Optional save(User user);
+
+ User findUserWithRolesByUsername(String username);
+
+ void deleteUser(String username);
+
+ void deleteUserById(Long id);
+
+ boolean isExistsById(Long id);
+
+ boolean isExistsByUsername(String username);
+
+}
diff --git a/src/main/java/com/emobile/springtodo/repository/impl/TodoRepositoryImpl.java b/src/main/java/com/emobile/springtodo/repository/impl/TodoRepositoryImpl.java
new file mode 100644
index 00000000..e4e27f5b
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/repository/impl/TodoRepositoryImpl.java
@@ -0,0 +1,123 @@
+package com.emobile.springtodo.repository.impl;
+
+import com.emobile.springtodo.dto.TodoCreateDto;
+import com.emobile.springtodo.enums.Status;
+import com.emobile.springtodo.mapper.RowMappers;
+import com.emobile.springtodo.model.Todo;
+import com.emobile.springtodo.repository.TodoRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+
+import static com.emobile.springtodo.enums.Status.CREATED;
+
+
+@RequiredArgsConstructor
+@Repository
+public class TodoRepositoryImpl implements TodoRepository {
+ private final JdbcTemplate jdbcTemplate;
+ private final RowMappers mapper;
+
+ @Override
+ public List findAll(int limit, int offset) {
+ String sql = """
+ SELECT * FROM todos
+ ORDER BY id
+ LIMIT ? OFFSET ?
+ """;
+
+ return jdbcTemplate.query(sql, mapper.todoRowMapper, limit, offset);
+ }
+
+ @Override
+ public Optional create(TodoCreateDto dto) {
+ String sql = "INSERT INTO todos (title, description,status) VALUES (?,?,?) RETURNING *";
+
+ return jdbcTemplate.query(sql, mapper.todoRowMapper, dto.getTitle(), dto.getDescription(), CREATED.toString())
+ .stream()
+ .findFirst();
+ }
+
+ @Override
+ public void addTodoToUser(String todoTitle, String userName) {
+ String sql = """
+ INSERT INTO users_todos (user_id, todo_id)
+ VALUES (
+ (SELECT id FROM users WHERE username = ?),
+ (SELECT id FROM todos WHERE title = ?)
+ )
+ """;
+
+ jdbcTemplate.update(sql, userName, todoTitle);
+ }
+
+ @Override
+ public List findTodosByUserName(String userName, int limit, int offset) {
+ String sql = """
+ SELECT t.*
+ FROM todos t
+ JOIN users_todos ut ON t.id = ut.todo_id
+ WHERE ut.user_id = (SELECT id FROM users WHERE username = ?)
+ ORDER BY t.id
+ LIMIT ? OFFSET ?
+ """;
+
+ return jdbcTemplate.query(sql, mapper.todoRowMapper, userName, limit, offset);
+ }
+
+ @Override
+ public int deleteTodoFromUserByUsername(String username, String todoTitle) {
+ String sql = """
+ DELETE FROM users_todos
+ WHERE user_id = (SELECT id FROM users WHERE username = ?)
+ AND todo_id = (SELECT id FROM todos WHERE title = ?)
+ """;
+
+ return jdbcTemplate.update(sql, username, todoTitle);
+ }
+
+ @Override
+ @Transactional
+ public int updateStatus(String username, Status status, String todoTitle) {
+ String sql = """
+ UPDATE todos
+ SET status = ?
+ WHERE id = (
+ SELECT t.id
+ FROM todos t
+ JOIN users_todos ut ON t.id = ut.todo_id
+ JOIN users u ON u.id = ut.user_id
+ WHERE u.username = ?
+ AND t.title = ?
+ LIMIT 1
+ )
+ """;
+
+ return jdbcTemplate.update(sql, status.toString(), username, todoTitle);
+ }
+
+ @Override
+ public int countAllTodos() {
+ String sql = "SELECT COUNT(todos) FROM todos";
+ return jdbcTemplate.queryForObject(sql, Integer.class);
+ }
+
+ @Override
+ public int countTodosByUserName(String userName) {
+ String sql = "SELECT COUNT(*) FROM users_todos ut " +
+ "JOIN users u ON ut.user_id = u.id " +
+ "WHERE u.username = ?";
+ return jdbcTemplate.queryForObject(sql, Integer.class, userName);
+ }
+
+ @Override
+ public void deleteTodoById(Long id) {
+ String sql = "DELETE FROM todos WHERE id = ?";
+ jdbcTemplate.update(sql, id);
+ }
+
+}
diff --git a/src/main/java/com/emobile/springtodo/repository/impl/UserRepositoryImpl.java b/src/main/java/com/emobile/springtodo/repository/impl/UserRepositoryImpl.java
new file mode 100644
index 00000000..0b4e8bd0
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/repository/impl/UserRepositoryImpl.java
@@ -0,0 +1,96 @@
+package com.emobile.springtodo.repository.impl;
+
+import com.emobile.springtodo.enums.Role;
+import com.emobile.springtodo.mapper.RowMappers;
+import com.emobile.springtodo.model.User;
+import com.emobile.springtodo.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+
+@Repository
+@RequiredArgsConstructor
+public class UserRepositoryImpl implements UserRepository {
+
+ private final JdbcTemplate jdbcTemplate;
+ private final RowMappers mapper;
+
+ @Override
+ public List findAll() {
+ String sql = "select * from users";
+ return jdbcTemplate.query(sql, mapper.userRowMapper);
+
+ }
+
+ @Override
+ public Optional save(User user) {
+ String sql = "INSERT INTO users (username, password) VALUES (?, ?) RETURNING *";
+ Optional savedUser = jdbcTemplate.query(sql, mapper.userRowMapper,
+ user.getUsername(), user.getPassword())
+ .stream()
+ .findFirst();
+ if (savedUser.isPresent()) {
+ addRoleToUser(savedUser.get().getId(), user.getRoles());
+ return savedUser;
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public User findUserWithRolesByUsername(String username) {
+ String userSql = "SELECT id, username, password FROM users WHERE username = ?";
+ User user = jdbcTemplate.queryForObject(userSql, mapper.userRowMapper, username);
+
+ String rolesSql = """
+ SELECT r.name as role_name
+ FROM users_roles ur
+ JOIN roles r ON ur.role_id = r.id
+ WHERE ur.user_id = ?
+ """;
+
+ assert user != null;
+ List roles = jdbcTemplate.query(rolesSql, mapper.roleRowMapper, user.getId());
+ user.setRoles(roles);
+
+ return user;
+ }
+
+ @Override
+ public void deleteUser(String username) {
+ String sql = "DELETE FROM users WHERE username = ?";
+ jdbcTemplate.update(sql, username);
+ }
+
+ @Override
+ public void deleteUserById(Long id) {
+ String sql = "DELETE FROM users WHERE id = ?";
+ jdbcTemplate.update(sql, id);
+ }
+
+ @Override
+ public boolean isExistsById(Long id) {
+ String sql = "SELECT COUNT(*) FROM users WHERE id = ?";
+ Integer count = jdbcTemplate.queryForObject(sql, Integer.class, id);
+ return count != null && count > 0;
+ }
+
+ public boolean isExistsByUsername(String username) {
+ String sql = "SELECT COUNT(*) FROM users WHERE username = ?";
+ Integer count = jdbcTemplate.queryForObject(sql, Integer.class, username);
+ return count != null && count > 0;
+ }
+
+
+ private void addRoleToUser(Long userId, List roles) {
+ String sql = "INSERT INTO users_roles (user_id, role_id) " +
+ "SELECT ?, id FROM roles WHERE name = ?";
+
+ roles.forEach(role -> jdbcTemplate.update(sql, userId, role.toString()));
+ }
+
+}
diff --git a/src/main/java/com/emobile/springtodo/security/CustomUserDetailsService.java b/src/main/java/com/emobile/springtodo/security/CustomUserDetailsService.java
new file mode 100644
index 00000000..4abb8646
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/security/CustomUserDetailsService.java
@@ -0,0 +1,32 @@
+package com.emobile.springtodo.security;
+
+
+import com.emobile.springtodo.model.User;
+import com.emobile.springtodo.repository.impl.UserRepositoryImpl;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class CustomUserDetailsService implements UserDetailsService {
+
+ private final UserRepositoryImpl userRepository;
+
+ @Override
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ User user = userRepository.findUserWithRolesByUsername(username);
+ return new org.springframework.security.core.userdetails.User(
+ user.getUsername(),
+ user.getPassword(),
+ user.getRoles().stream()
+ .map(role -> new SimpleGrantedAuthority( "ROLE_"+role))
+ .toList()
+ );
+ }
+}
+
diff --git a/src/main/java/com/emobile/springtodo/security/JwtAuthFilter.java b/src/main/java/com/emobile/springtodo/security/JwtAuthFilter.java
new file mode 100644
index 00000000..80b25f8b
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/security/JwtAuthFilter.java
@@ -0,0 +1,60 @@
+package com.emobile.springtodo.security;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.lang.NonNull;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+@Component
+@RequiredArgsConstructor
+public class JwtAuthFilter extends OncePerRequestFilter {
+
+ private final JwtService jwtService;
+ private final UserDetailsService userDetailsService;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, @NonNull
+ HttpServletResponse response, @NonNull
+ FilterChain filterChain) throws ServletException, IOException {
+
+ final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
+ final String jwt;
+ final String username;
+
+ if (authHeader == null || !authHeader.startsWith("Bearer ")) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ jwt = authHeader.substring(7);
+ username = jwtService.extractUsername(jwt);
+
+ if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
+ UserDetails userDetails = userDetailsService.loadUserByUsername(username);
+
+
+ if (jwtService.isTokenValid(jwt, userDetails)) {
+ UsernamePasswordAuthenticationToken authToken =
+ new UsernamePasswordAuthenticationToken(
+ userDetails, null, userDetails.getAuthorities());
+
+ authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+ SecurityContextHolder.getContext().setAuthentication(authToken);
+ }
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/src/main/java/com/emobile/springtodo/security/JwtService.java b/src/main/java/com/emobile/springtodo/security/JwtService.java
new file mode 100644
index 00000000..d758036e
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/security/JwtService.java
@@ -0,0 +1,47 @@
+package com.emobile.springtodo.security;
+
+import com.emobile.springtodo.config.JwtConfig;
+import io.jsonwebtoken.Jwts;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+
+@Service
+@RequiredArgsConstructor
+public class JwtService {
+ private final JwtConfig jwtConfig;
+
+ public String generateToken(UserDetails userDetails) {
+ return Jwts.builder()
+ .subject(userDetails.getUsername())
+ .claim("roles", userDetails.getAuthorities().stream()
+ .map(GrantedAuthority::getAuthority)
+ .toList())
+ .issuedAt(new Date())
+ .expiration(new Date(System.currentTimeMillis() + jwtConfig.getExpirationMs()))
+ .signWith(jwtConfig.getSecretKey())
+ .compact();
+ }
+
+ public boolean isTokenValid(String token, UserDetails userDetails) {
+ String username = extractUsername(token);
+ return username.equals(userDetails.getUsername());
+ }
+
+ public String extractUsername(String token) {
+ return Jwts.parser()
+ .verifyWith(jwtConfig.getSecretKey())
+ .build()
+ .parseSignedClaims(token)
+ .getPayload()
+ .getSubject();
+ }
+
+ public Long getExpirationTime() {
+ return jwtConfig.getExpirationMs();
+ }
+
+}
\ No newline at end of file
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..0251366b
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/service/TodoService.java
@@ -0,0 +1,139 @@
+package com.emobile.springtodo.service;
+
+import com.emobile.springtodo.dto.PageResponse;
+import com.emobile.springtodo.dto.TodoCreateDto;
+import com.emobile.springtodo.dto.TodoDto;
+import com.emobile.springtodo.enums.Status;
+import com.emobile.springtodo.exception.TodoIsPresentException;
+import com.emobile.springtodo.exception.TodoNotFoundException;
+import com.emobile.springtodo.mapper.TodoMapper;
+import com.emobile.springtodo.repository.TodoRepository;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import jakarta.annotation.PostConstruct;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import org.springframework.cache.annotation.CacheConfig;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.cache.annotation.Caching;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@RequiredArgsConstructor
+@Data
+@Service
+@CacheConfig(cacheNames = "todos")
+public class TodoService {
+
+ private final TodoRepository repository;
+ private final TodoMapper mapper;
+ private final MeterRegistry meterRegistry;
+
+ private Counter todoCreatedCounter;
+ private Counter todoDeletedCounter;
+
+ @PostConstruct
+ public void initMetrics() {
+ this.todoCreatedCounter = Counter.builder("todo.created.total")
+ .description("Total number of todos created")
+ .register(meterRegistry);
+
+ this.todoDeletedCounter = Counter.builder("todo.deleted.total")
+ .description("Total number of todos deleted")
+ .register(meterRegistry);
+
+ }
+
+ @CacheEvict(allEntries = true)
+ @Transactional
+ public TodoDto add(TodoCreateDto dto) {
+ TodoDto result = mapper.toDto(repository.create(dto).orElseThrow(() -> new TodoIsPresentException(dto.getTitle())));
+ todoCreatedCounter.increment();
+ return result;
+ }
+
+ @CacheEvict(allEntries = true)
+ @Transactional
+ public void addTodoUser(String todoTitle, String userName) {
+ repository.addTodoToUser(todoTitle, userName);
+ }
+
+ @Cacheable(key = "'all_todos_limit_' + #limit + '_offset_' + #offset")
+ @Transactional(readOnly = true)
+ public PageResponse getAll(int limit, int offset) {
+ List content = repository
+ .findAll(limit, offset)
+ .stream()
+ .map(mapper::toDto)
+ .toList();
+
+ int total = repository.countAllTodos();
+
+ return createPageResponse(content, total, limit, offset);
+ }
+
+ @Cacheable(key = "'todos_user_' + #userName + '_limit_' + #limit + '_offset_' + #offset")
+ @Transactional(readOnly = true)
+ public PageResponse getTodoByUserName(String userName, int limit, int offset) {
+ List content = repository
+ .findTodosByUserName(userName, limit, offset)
+ .stream()
+ .map(mapper::toDto)
+ .toList();
+
+ int total = repository.countTodosByUserName(userName);
+
+ return createPageResponse(content, total, limit, offset);
+ }
+
+ @Caching(evict = {
+ @CacheEvict(key = "'all_todos_limit_*'"),
+ @CacheEvict(key = "'todos_user_' + #username + '_*'")
+ })
+ @Transactional
+ public boolean removeTodoFromUser(String username, String title) {
+ if (repository.deleteTodoFromUserByUsername(username, title) != 1) {
+ throw new TodoNotFoundException(title);
+ }
+ return true;
+ }
+
+ @Caching(evict = {
+ @CacheEvict(key = "'all_todos_limit_*'"),
+ @CacheEvict(key = "'todos_user_' + #username + '_*'")
+ })
+ public boolean updateStatusTodoCurrentUser(String username, Status status, String title) {
+ if (repository.updateStatus(username, status, title) != 1) {
+ throw new TodoNotFoundException(title);
+ }
+ return true;
+ }
+
+ @CacheEvict(allEntries = true)
+ @Transactional
+ public void delete(Long id) {
+ repository.deleteTodoById(id);
+ todoDeletedCounter.increment();
+ }
+
+ private PageResponse createPageResponse(List content, int total, int limit, int offset) {
+ int currentPage = offset / limit;
+ int totalPages = (int) Math.ceil((double) total / limit);
+ boolean hasNext = (offset + limit) < total;
+ boolean hasPrevious = offset > 0;
+
+ return PageResponse.builder()
+ .content(content)
+ .total(total)
+ .limit(limit)
+ .offset(offset)
+ .hasNext(hasNext)
+ .hasPrevious(hasPrevious)
+ .currentPage(currentPage)
+ .totalPages(totalPages)
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/emobile/springtodo/service/UserService.java b/src/main/java/com/emobile/springtodo/service/UserService.java
new file mode 100644
index 00000000..11734059
--- /dev/null
+++ b/src/main/java/com/emobile/springtodo/service/UserService.java
@@ -0,0 +1,108 @@
+package com.emobile.springtodo.service;
+
+import com.emobile.springtodo.dto.AuthRequest;
+import com.emobile.springtodo.dto.UserDto;
+import com.emobile.springtodo.enums.Role;
+import com.emobile.springtodo.exception.UserIsPresentException;
+import com.emobile.springtodo.exception.UserNotFoundException;
+import com.emobile.springtodo.mapper.UserMapper;
+import com.emobile.springtodo.model.User;
+import com.emobile.springtodo.repository.UserRepository;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.MeterRegistry;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import org.springframework.cache.annotation.CacheConfig;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@RequiredArgsConstructor
+@Service
+@CacheConfig(cacheNames = "users")
+public class UserService {
+
+ private final UserRepository repository;
+ private final PasswordEncoder passwordEncoder;
+ private final UserMapper userMapper;
+ private final MeterRegistry meterRegistry;
+
+ private Counter userRegisteredCounter;
+ private Counter userDeletedCounter;
+ private AtomicInteger totalUsers = new AtomicInteger(0);
+
+ @PostConstruct
+ public void initMetrics() {
+ this.userRegisteredCounter = Counter.builder("user.registered.total")
+ .description("Total number of user registrations")
+ .register(meterRegistry);
+
+ this.userDeletedCounter = Counter.builder("user.deleted.total")
+ .description("Total number of users deleted")
+ .register(meterRegistry);
+
+ Gauge.builder("user.total.current", totalUsers, AtomicInteger::get)
+ .description("Current number of registered users")
+ .register(meterRegistry);
+
+ updateUserCount();
+ }
+
+ private void updateUserCount() {
+ totalUsers.set(repository.findAll().size());
+ }
+
+ @CacheEvict(allEntries = true)
+ @Transactional
+ public UserDto save(AuthRequest request) {
+ List roles = new ArrayList<>();
+ roles.add(Role.USER);
+ User user = User.builder()
+ .username(request.getUsername())
+ .password(passwordEncoder.encode(request.getPassword()))
+ .roles(roles)
+ .build();
+ repository.save(user).orElseThrow(()->new UserIsPresentException(request.getUsername()));
+
+ userRegisteredCounter.increment();
+ totalUsers.incrementAndGet();
+
+ return userMapper.toDto(user);
+ }
+
+ @Cacheable(key = "'all_users'")
+ @Transactional(readOnly = true)
+ public List getAllUsers() {
+ return repository.findAll();
+ }
+
+ @CacheEvict(allEntries = true)
+ @Transactional
+ public void deleteUser(String username) {
+ if (!repository.isExistsByUsername(username)) {
+ throw new UserNotFoundException(username);
+ }
+ repository.deleteUser(username);
+ userDeletedCounter.increment();
+ totalUsers.decrementAndGet();
+ }
+
+ @CacheEvict(allEntries = true)
+ @Transactional
+ public void deleteUserById(Long id) {
+ if (!repository.isExistsById(id)) {
+ throw new UserNotFoundException(id);
+ }
+ repository.deleteUserById(id);
+ userDeletedCounter.increment();
+ totalUsers.decrementAndGet();
+ }
+
+}
\ No newline at end of file
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.yaml b/src/main/resources/application.yaml
new file mode 100644
index 00000000..222b87f0
--- /dev/null
+++ b/src/main/resources/application.yaml
@@ -0,0 +1,47 @@
+spring:
+ application:
+ name: SpringToDo
+ datasource:
+ url: ${SPRING_DATASOURCE_URL}
+ username: ${SPRING_DATASOURCE_USERNAME}
+ password: ${SPRING_DATASOURCE_PASSWORD}
+ driver-class-name: org.postgresql.Driver
+ liquibase:
+ change-log: classpath:/db/changelog/changelog-master.yaml
+ data:
+ redis:
+ host: ${SPRING_DATA_REDIS_HOST}
+ port: ${SPRING_DATA_REDIS_PORT}
+ password:
+ database: 0
+ timeout: 2000ms
+ lettuce:
+ pool:
+ max-active: 8
+ max-idle: 8
+ min-idle: 2
+ max-wait: -1ms
+ cache:
+ type: redis
+ redis:
+ time-to-live: 1800000 # 30 minutes in milliseconds
+ cache-null-values: false
+ use-key-prefix: true
+ key-prefix: "todo_app:"
+jwt:
+ secret: ${JWT_SECRET}
+ expiration-ms: 86400000
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health, metrics, info
+ base-path: /actuator
+ endpoint:
+ health:
+ show-details: always
+ metrics:
+ access: read_only
+ info:
+ access: read_only
\ No newline at end of file
diff --git a/src/main/resources/db/changelog/changelog-master.yaml b/src/main/resources/db/changelog/changelog-master.yaml
new file mode 100644
index 00000000..95768114
--- /dev/null
+++ b/src/main/resources/db/changelog/changelog-master.yaml
@@ -0,0 +1,5 @@
+databaseChangeLog:
+ - include:
+ file: db/changelog/scripts/preconditions.yaml
+ - include:
+ file: db/changelog/scripts/insert_roles.yaml
diff --git a/src/main/resources/db/changelog/scripts/insert_roles.yaml b/src/main/resources/db/changelog/scripts/insert_roles.yaml
new file mode 100644
index 00000000..15a4f224
--- /dev/null
+++ b/src/main/resources/db/changelog/scripts/insert_roles.yaml
@@ -0,0 +1,17 @@
+databaseChangeLog:
+ - changeSet:
+ id: 2
+ author: sergey_white
+ changes:
+ - insert:
+ tableName: roles
+ columns:
+ - column:
+ name: name
+ value: 'ADMIN'
+ - insert:
+ tableName: roles
+ columns:
+ - column:
+ name: name
+ value: 'USER'
\ No newline at end of file
diff --git a/src/main/resources/db/changelog/scripts/preconditions.yaml b/src/main/resources/db/changelog/scripts/preconditions.yaml
new file mode 100644
index 00000000..26b963d1
--- /dev/null
+++ b/src/main/resources/db/changelog/scripts/preconditions.yaml
@@ -0,0 +1,115 @@
+databaseChangeLog:
+ - changeSet:
+ id: 1
+ author: sergey_white
+ changes:
+ # 1. roles
+ - createTable:
+ tableName: roles
+ columns:
+ - column:
+ name: id
+ type: bigserial
+ constraints:
+ primaryKey: true
+ nullable: false
+ - column:
+ name: name
+ type: varchar(50)
+ constraints:
+ nullable: false
+ unique: true
+
+ # 2. users
+ - createTable:
+ tableName: users
+ columns:
+ - column:
+ name: id
+ type: bigserial
+ constraints:
+ primaryKey: true
+ nullable: false
+ - column:
+ name: username
+ type: varchar(100)
+ constraints:
+ nullable: false
+ unique: true
+ - column:
+ name: password
+ type: varchar(255)
+
+ # 3. todos
+ - createTable:
+ tableName: todos
+ columns:
+ - column:
+ name: id
+ type: bigserial
+ constraints:
+ primaryKey: true
+ nullable: false
+ - column:
+ name: title
+ type: varchar(200)
+ - column:
+ name: description
+ type: text
+ - column:
+ name: status
+ type: text
+
+ # 4. users - roles
+ - createTable:
+ tableName: users_roles
+ columns:
+ - column:
+ name: user_id
+ type: bigint
+ constraints:
+ nullable: false
+ foreignKeyName: fk_user_role_user
+ references: users(id)
+ deleteCascade: true
+ - column:
+ name: role_id
+ type: bigint
+ constraints:
+ nullable: false
+ foreignKeyName: fk_user_role_role
+ references: roles(id)
+ deleteCascade: true
+
+ # 5. Первичный ключ для users_roles
+ - addPrimaryKey:
+ tableName: users_roles
+ columnNames: user_id, role_id
+ constraintName: pk_users_roles
+
+ # 6. users - todos
+ - createTable:
+ tableName: users_todos
+ columns:
+ - column:
+ name: user_id
+ type: bigint
+ constraints:
+ nullable: false
+ foreignKeyName: fk_user_todo_user
+ references: users(id)
+ deleteCascade: true
+ - column:
+ name: todo_id
+ type: bigint
+ constraints:
+ nullable: false
+ foreignKeyName: fk_user_todo_todo
+ references: todos(id)
+ deleteCascade: true
+
+ # 7. Первичный ключ для users_todos
+ - addPrimaryKey:
+ tableName: users_todos
+ columnNames: user_id, todo_id
+ constraintName: pk_users_todos
\ 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
index 3114a50b..8b1a1188 100644
--- a/src/test/java/com/emobile/springtodo/SpringToDoApplicationTests.java
+++ b/src/test/java/com/emobile/springtodo/SpringToDoApplicationTests.java
@@ -1,10 +1,13 @@
package com.emobile.springtodo;
+import com.emobile.springtodo.controller.AbstractIntegrationTest;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
-class SpringToDoApplicationTests {
+@ActiveProfiles("test")
+class SpringToDoApplicationTests extends AbstractIntegrationTest {
@Test
void contextLoads() {
diff --git a/src/test/java/com/emobile/springtodo/controller/AbstractIntegrationTest.java b/src/test/java/com/emobile/springtodo/controller/AbstractIntegrationTest.java
new file mode 100644
index 00000000..0481a3e0
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/controller/AbstractIntegrationTest.java
@@ -0,0 +1,43 @@
+
+package com.emobile.springtodo.controller;
+
+import org.springframework.boot.test.autoconfigure.core.AutoConfigureCache;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.jdbc.Sql;
+
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@Testcontainers
+@ActiveProfiles("test")
+@AutoConfigureCache
+@Sql(scripts = {
+ "/sql/cleanup.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-base-data.sql"
+}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+public abstract class AbstractIntegrationTest {
+
+ @Container
+ static PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
+ .withDatabaseName("testdb")
+ .withUsername("test")
+ .withPassword("test");
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", postgres::getJdbcUrl);
+ registry.add("spring.datasource.username", postgres::getUsername);
+ registry.add("spring.datasource.password", postgres::getPassword);
+ registry.add("jwt.secret", () -> "testSecretKeyWhichIsLongEnoughForHS256Algorithm");
+ registry.add("spring.data.redis.host", () -> "localhost");
+ registry.add("spring.data.redis.port", () -> 6379);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/emobile/springtodo/controller/AuthControllerIntegrationTest.java b/src/test/java/com/emobile/springtodo/controller/AuthControllerIntegrationTest.java
new file mode 100644
index 00000000..16ac3fa1
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/controller/AuthControllerIntegrationTest.java
@@ -0,0 +1,76 @@
+package com.emobile.springtodo.controller;
+
+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.http.MediaType;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@DisplayName("Интеграционные тесты контроллера авторизации")
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+class AuthControllerIntegrationTest extends AbstractIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ @DisplayName("Должен успешно зарегистрировать нового пользователя")
+ void register_ShouldReturnUserDto() throws Exception {
+ String requestBody = """
+ {
+ "username": "testuser",
+ "password": "password123"
+ }
+ """;
+
+ MvcResult result = mockMvc.perform(post("/auth/register")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBody))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ String expectedResponse = """
+ {
+ "username": "testuser"
+ }
+ """;
+ JSONAssert.assertEquals(expectedResponse, result.getResponse().getContentAsString(), false);
+ }
+
+ @Test
+ @DisplayName("Должен успешно выполнить вход пользователя и вернуть JWT-токен")
+ void login_ShouldReturnJwtToken() throws Exception {
+ String registerBody = """
+ {
+ "username": "testuser",
+ "password": "password123"
+ }
+ """;
+ mockMvc.perform(post("/auth/register")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(registerBody));
+
+ String loginBody = """
+ {
+ "username": "testuser",
+ "password": "password123"
+ }
+ """;
+ MvcResult result = mockMvc.perform(post("/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(loginBody))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ String response = result.getResponse().getContentAsString();
+ assert response.contains("token");
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/emobile/springtodo/controller/TodoControllerIntegrationTest.java b/src/test/java/com/emobile/springtodo/controller/TodoControllerIntegrationTest.java
new file mode 100644
index 00000000..476c4d6c
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/controller/TodoControllerIntegrationTest.java
@@ -0,0 +1,149 @@
+package com.emobile.springtodo.controller;
+
+import com.emobile.springtodo.dto.TodoCreateDto;
+import org.junit.jupiter.api.Assertions;
+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.http.MediaType;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@DisplayName("Интеграционные тесты контроллера задач")
+@Sql(scripts = "/sql/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
+class TodoControllerIntegrationTest extends AbstractIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ private String asJsonString(final Object obj) throws Exception {
+ return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(obj);
+ }
+
+ @Test
+ @DisplayName("Должен успешно создать задачу с ролью ADMIN")
+ @WithMockUser(roles = "ADMIN")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldCreateTodoAsAdmin() throws Exception {
+ TodoCreateDto dto = new TodoCreateDto("Test Todo", "Test Description");
+
+ String result = mockMvc.perform(post("/todos")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(asJsonString(dto)))
+ .andExpect(status().isOk())
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+
+ String expectedJson = """
+ {
+ "title": "Test Todo",
+ "description": "Test Description",
+ "status": "CREATED"
+ }
+ """;
+
+ JSONAssert.assertEquals(expectedJson, result, false);
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 403 при попытке создания задачи без роли ADMIN")
+ @WithMockUser(roles = "USER")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldReturnForbiddenWhenNonAdminCreatesTodo() throws Exception {
+ TodoCreateDto dto = new TodoCreateDto("Test Todo", "Test Description");
+
+ MvcResult result = mockMvc.perform(post("/todos")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(asJsonString(dto)))
+ .andExpect(status().isForbidden())
+ .andReturn();
+
+ String response = result.getResponse().getContentAsString();
+ assertTrue(response.contains("Access Denied"));
+ }
+
+ @Test
+ @DisplayName("Должен успешно удалить задачу пользователя")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql", "/sql/insert-user-todo-for-removal.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldRemoveTodoFromUser() throws Exception {
+ String result = mockMvc.perform(delete("/todos/current-user/{todoName}", "Todo to remove")
+ .with(user("testuser").password("password").roles("USER")))
+ .andExpect(status().isOk())
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+
+ Assertions.assertTrue(Boolean.parseBoolean(result));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 404 при попытке удаления несуществующей задачи пользователя")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql", "/sql/insert-user.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldReturnNotFoundWhenRemovingNonExistentTodo() throws Exception {
+ MvcResult result = mockMvc.perform(delete("/todos/current-user/{todoName}", "NonExistentTodo")
+ .with(user("testuser").password("password").roles("USER")))
+ .andExpect(status().isNotFound())
+ .andReturn();
+
+ String response = result.getResponse().getContentAsString();
+ assertTrue(response.contains("not found"));
+ }
+
+ @Test
+ @DisplayName("Должен успешно обновить статус задачи")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql", "/sql/insert-user-todo-for-status.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldUpdateTodoStatus() throws Exception {
+ String result = mockMvc.perform(patch("/todos/current-user/{title}/status", "Status Todo")
+ .with(user("testuser").password("password").roles("USER"))
+ .param("status", "CREATED"))
+ .andExpect(status().isOk())
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+
+ Assertions.assertTrue(Boolean.parseBoolean(result));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 404 при попытке обновления статуса несуществующей задачи")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql", "/sql/insert-user.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldReturnNotFoundWhenUpdatingNonExistentTodoStatus() throws Exception {
+ MvcResult result = mockMvc.perform(patch("/todos/current-user/{title}/status", "NonExistentTodo")
+ .with(user("testuser").password("password").roles("USER"))
+ .param("status", "CREATED"))
+ .andExpect(status().isNotFound())
+ .andReturn();
+
+ String response = result.getResponse().getContentAsString();
+ assertTrue(response.contains("not found"));
+ }
+
+ @Test
+ @DisplayName("Должен успешно удалить задачу с ролью ADMIN")
+ @WithMockUser(roles = "ADMIN")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql", "/sql/insert-todo-for-deletion.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldDeleteTodo() throws Exception {
+ mockMvc.perform(delete("/todos/1"))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 403 при попытке удаления задачи без роли ADMIN")
+ @WithMockUser(roles = "USER")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql", "/sql/insert-todo-for-deletion.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldReturnForbiddenWhenNonAdminDeletesTodo() throws Exception {
+ mockMvc.perform(delete("/todos/1"))
+ .andExpect(status().isForbidden());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/emobile/springtodo/controller/UserControllerIntegrationTest.java b/src/test/java/com/emobile/springtodo/controller/UserControllerIntegrationTest.java
new file mode 100644
index 00000000..7e2e426c
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/controller/UserControllerIntegrationTest.java
@@ -0,0 +1,118 @@
+package com.emobile.springtodo.controller;
+
+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.http.MediaType;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+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.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@DisplayName("Интеграционные тесты контроллера пользователей")
+@Sql(scripts = "/sql/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
+class UserControllerIntegrationTest extends AbstractIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ @DisplayName("Должен успешно вернуть список пользователей с ролью ADMIN")
+ @WithMockUser(roles = "ADMIN")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql", "/sql/insert-user.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldGetAllUsersAsAdmin() throws Exception {
+ String result = mockMvc.perform(get("/users")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+
+ String expectedJson = """
+ [
+ {
+ "username": "testuser",
+ "password": "password"
+ }
+ ]
+ """;
+
+ JSONAssert.assertEquals(expectedJson, result, false);
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 403 при попытке получить список пользователей без роли ADMIN")
+ @WithMockUser(roles = "USER")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql", "/sql/insert-user.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldReturnForbiddenWhenNonAdminGetsAllUsers() throws Exception {
+ MvcResult result = mockMvc.perform(get("/users")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isForbidden())
+ .andReturn();
+
+ String response = result.getResponse().getContentAsString();
+ assertTrue(response.contains("Access Denied"));
+ }
+
+ @Test
+ @DisplayName("Должен успешно удалить текущего пользователя")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql", "/sql/insert-user.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldDeleteCurrentUser() throws Exception {
+ mockMvc.perform(delete("/users")
+ .with(user("testuser").password("password").roles("USER")))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 404 при попытке удаления несуществующего пользователя")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldReturnNotFoundWhenDeletingNonExistentUser() throws Exception {
+ MvcResult result = mockMvc.perform(delete("/users")
+ .with(user("nonexistent").password("password").roles("USER")))
+ .andExpect(status().isNotFound())
+ .andReturn();
+
+ String response = result.getResponse().getContentAsString();
+ assertTrue(response.contains("not found"));
+ }
+
+ @Test
+ @DisplayName("Должен успешно удалить пользователя по ID с ролью ADMIN")
+ @WithMockUser(roles = "ADMIN")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql", "/sql/insert-user.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldDeleteUserByIdAsAdmin() throws Exception {
+ mockMvc.perform(delete("/users/1"))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 403 при попытке удаления пользователя по ID без роли ADMIN")
+ @WithMockUser(roles = "USER")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql", "/sql/insert-user.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldReturnForbiddenWhenNonAdminDeletesUserById() throws Exception {
+ mockMvc.perform(delete("/users/1"))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 404 при попытке удаления несуществующего пользователя по ID")
+ @WithMockUser(roles = "ADMIN")
+ @Sql(scripts = {"/sql/cleanup.sql", "/sql/insert-base-data.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+ void shouldReturnNotFoundWhenDeletingNonExistentUserById() throws Exception {
+ MvcResult result = mockMvc.perform(delete("/users/999"))
+ .andExpect(status().isNotFound())
+ .andReturn();
+
+ String response = result.getResponse().getContentAsString();
+ assertTrue(response.contains("not found"));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/emobile/springtodo/repository/TodoRepositoryImplTest.java b/src/test/java/com/emobile/springtodo/repository/TodoRepositoryImplTest.java
new file mode 100644
index 00000000..289811d0
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/repository/TodoRepositoryImplTest.java
@@ -0,0 +1,275 @@
+package com.emobile.springtodo.repository;
+
+import com.emobile.springtodo.dto.TodoCreateDto;
+import com.emobile.springtodo.enums.Status;
+import com.emobile.springtodo.mapper.RowMappers;
+import com.emobile.springtodo.model.Todo;
+import com.emobile.springtodo.repository.impl.TodoRepositoryImpl;
+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.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.jdbc.Sql;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DataJpaTest
+@Import({TodoRepositoryImpl.class, RowMappers.class})
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@Testcontainers
+@ActiveProfiles("test")
+@DisplayName("Тесты TodoRepositoryImpl с готовыми SQL скриптами")
+class TodoRepositoryImplTest {
+
+ @Container
+ static PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", postgres::getJdbcUrl);
+ registry.add("spring.datasource.username", postgres::getUsername);
+ registry.add("spring.datasource.password", postgres::getPassword);
+ }
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @Autowired
+ private TodoRepositoryImpl todoRepository;
+
+ @Test
+ @DisplayName("Должен найти все задачи с пагинацией")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-todos.sql"
+ })
+ void findAll_ShouldReturnTodosWithPagination() {
+ List todos = todoRepository.findAll(10, 0);
+
+ assertEquals(2, todos.size());
+ assertTrue(todos.stream().anyMatch(t -> t.getTitle().equals("Todo 1")));
+ assertTrue(todos.stream().anyMatch(t -> t.getTitle().equals("Todo 2")));
+ assertTrue(todos.stream().anyMatch(t -> t.getStatus() == Status.CREATED));
+ assertTrue(todos.stream().anyMatch(t -> t.getStatus() == Status.IN_PROGRESS));
+ }
+
+ @Test
+ @DisplayName("Должен создать новую задачу")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql"
+ })
+ void create_ShouldCreateNewTodo() {
+ TodoCreateDto dto = new TodoCreateDto("New Todo", "New description");
+
+ Optional createdTodo = todoRepository.create(dto);
+
+ assertTrue(createdTodo.isPresent());
+ assertEquals("New Todo", createdTodo.get().getTitle());
+ assertEquals("New description", createdTodo.get().getDescription());
+ assertEquals(Status.CREATED, createdTodo.get().getStatus());
+
+ }
+
+ @Test
+ @DisplayName("Должен добавить задачу пользователю")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-todo-and-user.sql"
+ })
+ void addTodoToUser_ShouldAddTodoToUser() {
+ todoRepository.addTodoToUser("Test Todo", "testuser");
+
+ Integer count = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM users_todos WHERE user_id = (SELECT id FROM users WHERE username = 'testuser') AND todo_id = (SELECT id FROM todos WHERE title = 'Test Todo')",
+ Integer.class
+ );
+ assertEquals(1, count);
+ }
+
+ @Test
+ @DisplayName("Должен найти задачи по имени пользователя")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-user-todos.sql"
+ })
+ void findTodosByUserName_ShouldReturnUserTodos() {
+ List todos = todoRepository.findTodosByUserName("testuser", 10, 0);
+
+ assertEquals(1, todos.size());
+ assertEquals("User Todo 1", todos.getFirst().getTitle());
+ assertEquals("User Description 1", todos.getFirst().getDescription());
+ assertEquals(Status.CREATED, todos.getFirst().getStatus());
+ }
+
+ @Test
+ @DisplayName("Должен удалить задачу у пользователя")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-user-todo-for-removal.sql"
+ })
+ void deleteTodoFromUserByUsername_ShouldDeleteUserTodo() {
+ int deletedCount = todoRepository.deleteTodoFromUserByUsername("testuser", "Todo to remove");
+
+ assertEquals(1, deletedCount);
+
+ Integer count = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM users_todos WHERE user_id = (SELECT id FROM users WHERE username = 'testuser') AND todo_id = (SELECT id FROM todos WHERE title = 'Todo to remove')",
+ Integer.class
+ );
+ assertEquals(0, count);
+ }
+
+ @Test
+ @DisplayName("Должен обновить статус задачи")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-user-todo-for-status.sql"
+ })
+ void updateStatus_ShouldUpdateTodoStatus() {
+ int updatedCount = todoRepository.updateStatus("testuser", Status.IN_PROGRESS, "Status Todo");
+
+ assertEquals(1, updatedCount);
+
+ String status = jdbcTemplate.queryForObject(
+ "SELECT status FROM todos WHERE title = 'Status Todo'",
+ String.class
+ );
+ assertEquals("IN_PROGRESS", status);
+ }
+
+ @Test
+ @DisplayName("Должен удалить задачу по ID")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-todo-for-deletion.sql"
+ })
+ void deleteTodoById_ShouldDeleteTodo() {
+ Long todoId = jdbcTemplate.queryForObject(
+ "SELECT id FROM todos WHERE title = 'Todo to delete'",
+ Long.class
+ );
+
+ todoRepository.deleteTodoById(todoId);
+
+ Integer count = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM todos WHERE id = ?",
+ Integer.class,
+ todoId
+ );
+ assertEquals(0, count);
+ }
+
+ @Test
+ @DisplayName("Должен вернуть пустой список при отсутствии задач")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql"
+ })
+ void findAll_ShouldReturnEmptyListWhenNoTodos() {
+ List todos = todoRepository.findAll(10, 0);
+
+ assertTrue(todos.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Должен вернуть пустой список при отсутствии задач у пользователя")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-user.sql"
+ })
+ void findTodosByUserName_ShouldReturnEmptyListWhenNoUserTodos() {
+ List todos = todoRepository.findTodosByUserName("testuser", 10, 0);
+
+ assertTrue(todos.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 0 при попытке удалить несуществующую связь")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-todo-and-user.sql"
+ })
+ void deleteTodoFromUserByUsername_ShouldReturnZeroForNonExistingLink() {
+ int deletedCount = todoRepository.deleteTodoFromUserByUsername("testuser", "Non Existing Todo");
+
+ assertEquals(0, deletedCount);
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 0 при попытке обновить статус несуществующей связи")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-todo-and-user.sql"
+ })
+ void updateStatus_ShouldReturnZeroForNonExistingLink() {
+ int updatedCount = todoRepository.updateStatus("testuser", Status.IN_PROGRESS, "Non Existing Todo");
+
+ assertEquals(0, updatedCount);
+ }
+
+ @Test
+ @DisplayName("Должен корректно работать с пагинацией")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-todos.sql",
+ "/sql/insert-duplicate-todo.sql"
+ })
+ void findAll_ShouldWorkWithPagination() {
+ List firstPage = todoRepository.findAll(2, 0);
+ List secondPage = todoRepository.findAll(2, 2);
+
+ assertEquals(2, firstPage.size());
+ assertTrue(secondPage.size() <= 1); // Может быть 0 или 1 в зависимости от данных
+ }
+
+ @Test
+ @DisplayName("Должен корректно работать с пагинацией для пользовательских задач")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-user-todos.sql",
+ "/sql/insert-duplicate-todo.sql"
+ })
+ void findTodosByUserName_ShouldWorkWithPagination() {
+ List todos = todoRepository.findTodosByUserName("testuser", 5, 0);
+
+ assertEquals(1, todos.size());
+ assertEquals("User Todo 1", todos.getFirst().getTitle());
+ }
+
+ @Test
+ @DisplayName("Должен вернуть 0 при удалении несуществующей задачи")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql"
+ })
+ void deleteTodoFromUserByUsername_ShouldReturnZeroForNonExistingTodo() {
+ int deletedCount = todoRepository.deleteTodoFromUserByUsername("nonexistent", "nonexistent");
+
+ assertEquals(0, deletedCount);
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/java/com/emobile/springtodo/repository/UserRepositoryImplTest.java b/src/test/java/com/emobile/springtodo/repository/UserRepositoryImplTest.java
new file mode 100644
index 00000000..5a759167
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/repository/UserRepositoryImplTest.java
@@ -0,0 +1,219 @@
+package com.emobile.springtodo.repository;
+
+import com.emobile.springtodo.enums.Role;
+import com.emobile.springtodo.model.User;
+import com.emobile.springtodo.repository.impl.UserRepositoryImpl;
+import com.emobile.springtodo.mapper.RowMappers;
+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.context.annotation.Import;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.jdbc.Sql;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DataJpaTest
+@Import({UserRepositoryImpl.class, RowMappers.class}) // Добавьте RowMappers.class здесь
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@Testcontainers
+@ActiveProfiles("test")
+@DisplayName("Тесты UserRepositoryImpl с готовыми SQL скриптами")
+class UserRepositoryImplTest {
+
+ @Container
+ static PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", postgres::getJdbcUrl);
+ registry.add("spring.datasource.username", postgres::getUsername);
+ registry.add("spring.datasource.password", postgres::getPassword);
+ }
+
+ @Autowired
+ private UserRepositoryImpl userRepository;
+
+ // ... остальные тестовые методы без изменений ...
+ @Test
+ @DisplayName("Должен найти всех пользователей из test-users.sql")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/test-users.sql"
+ })
+ void findAll_ShouldReturnUsersFromTestScript() {
+ List users = userRepository.findAll();
+
+ assertEquals(2, users.size());
+ assertTrue(users.stream().anyMatch(u -> u.getUsername().equals("admin")));
+ assertTrue(users.stream().anyMatch(u -> u.getUsername().equals("user1")));
+ }
+
+ @Test
+ @DisplayName("Должен сохранить нового пользователя")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql"
+ })
+ void save_ShouldSaveNewUser() {
+ User newUser = User.builder()
+ .username("newuser")
+ .password("newpassword")
+ .roles(List.of(Role.USER))
+ .build();
+
+ Optional savedUser = userRepository.save(newUser);
+
+ assertTrue(savedUser.isPresent());
+ assertEquals("newuser", savedUser.get().getUsername());
+
+ }
+
+ @Test
+ @DisplayName("Должен найти пользователя с ролями по имени")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-user.sql"
+ })
+ void findUserWithRolesByUsername_ShouldReturnUserWithRoles() {
+ User user = userRepository.findUserWithRolesByUsername("testuser");
+
+ assertNotNull(user);
+ assertEquals("testuser", user.getUsername());
+ assertNotNull(user.getRoles());
+ assertTrue(user.getRoles().contains(Role.USER));
+ }
+
+ @Test
+ @DisplayName("Должен удалить пользователя по имени")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-user.sql"
+ })
+ void deleteUser_ShouldDeleteUserByUsername() {
+ assertTrue(userRepository.isExistsByUsername("testuser"));
+
+ userRepository.deleteUser("testuser");
+
+ assertFalse(userRepository.isExistsByUsername("testuser"));
+ }
+
+ @Test
+ @DisplayName("Должен удалить пользователя по ID")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-user.sql"
+ })
+ void deleteUserById_ShouldDeleteUserById() {
+ User user = userRepository.findUserWithRolesByUsername("testuser");
+ assertTrue(userRepository.isExistsById(user.getId()));
+
+ userRepository.deleteUserById(user.getId());
+
+ assertFalse(userRepository.isExistsById(user.getId()));
+ }
+
+ @Test
+ @DisplayName("Должен проверить существование пользователя по ID")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-user.sql"
+ })
+ void isExistsById_ShouldReturnTrueForExistingUser() {
+ User user = userRepository.findUserWithRolesByUsername("testuser");
+
+ assertTrue(userRepository.isExistsById(user.getId()));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть false для несуществующего ID")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql"
+ })
+ void isExistsById_ShouldReturnFalseForNonExistingId() {
+ assertFalse(userRepository.isExistsById(999L));
+ }
+
+ @Test
+ @DisplayName("Должен проверить существование пользователя по имени")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-user.sql"
+ })
+ void isExistsByUsername_ShouldReturnTrueForExistingUser() {
+ assertTrue(userRepository.isExistsByUsername("testuser"));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть false для несуществующего имени")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql"
+ })
+ void isExistsByUsername_ShouldReturnFalseForNonExistingUsername() {
+ assertFalse(userRepository.isExistsByUsername("nonexistent"));
+ }
+
+ @Test
+ @DisplayName("Должен найти admin пользователя с ролями")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql",
+ "/sql/insert-admin-user.sql"
+ })
+ void findUserWithRolesByUsername_ShouldReturnAdminWithRoles() {
+ User admin = userRepository.findUserWithRolesByUsername("admin");
+
+ assertNotNull(admin);
+ assertEquals("admin", admin.getUsername());
+ assertTrue(admin.getRoles().contains(Role.ADMIN));
+ assertTrue(admin.getRoles().contains(Role.USER));
+ }
+
+ @Test
+ @DisplayName("Должен вернуть пустой список при отсутствии пользователей")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql"
+ })
+ void findAll_ShouldReturnEmptyListWhenNoUsers() {
+ List users = userRepository.findAll();
+
+ assertTrue(users.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Должен обработать пустой результат при сохранении")
+ @Sql(scripts = {
+ "/sql/schema.sql",
+ "/sql/insert-base-data.sql"
+ })
+ void save_ShouldReturnEmptyOptionalWhenSaveFails() {
+ User user = User.builder()
+ .username("testuser")
+ .password("password")
+ .roles(List.of(Role.USER))
+ .build();
+
+ Optional result = userRepository.save(user);
+
+ assertTrue(result.isPresent());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/emobile/springtodo/service/TodoServiceTest.java b/src/test/java/com/emobile/springtodo/service/TodoServiceTest.java
new file mode 100644
index 00000000..baab03fa
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/service/TodoServiceTest.java
@@ -0,0 +1,323 @@
+package com.emobile.springtodo.service;
+
+import com.emobile.springtodo.dto.PageResponse;
+import com.emobile.springtodo.dto.TodoCreateDto;
+import com.emobile.springtodo.dto.TodoDto;
+import com.emobile.springtodo.enums.Status;
+import com.emobile.springtodo.exception.TodoIsPresentException;
+import com.emobile.springtodo.exception.TodoNotFoundException;
+import com.emobile.springtodo.mapper.TodoMapper;
+import com.emobile.springtodo.model.Todo;
+import com.emobile.springtodo.repository.TodoRepository;
+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.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+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)
+@DisplayName("Тесты для TodoService")
+class TodoServiceTest {
+
+ @InjectMocks
+ private TodoService todoService;
+
+ @Mock
+ private TodoRepository todoRepository;
+
+ @Mock
+ private TodoMapper todoMapper;
+
+ @Mock
+ private MeterRegistry meterRegistry;
+
+ private Counter todoCreatedCounter;
+ private Counter todoDeletedCounter;
+
+ @BeforeEach
+ void setUp() {
+ todoCreatedCounter = mock(Counter.class);
+ todoDeletedCounter = mock(Counter.class);
+
+ try (MockedStatic counterStatic = mockStatic(Counter.class)) {
+ Counter.Builder createdCounterBuilder = mock(Counter.Builder.class);
+ Counter.Builder deletedCounterBuilder = mock(Counter.Builder.class);
+
+ counterStatic.when(() -> Counter.builder("todo.created.total"))
+ .thenReturn(createdCounterBuilder);
+ when(createdCounterBuilder.description(anyString())).thenReturn(createdCounterBuilder);
+ when(createdCounterBuilder.register(meterRegistry)).thenReturn(todoCreatedCounter);
+
+ counterStatic.when(() -> Counter.builder("todo.deleted.total"))
+ .thenReturn(deletedCounterBuilder);
+ when(deletedCounterBuilder.description(anyString())).thenReturn(deletedCounterBuilder);
+ when(deletedCounterBuilder.register(meterRegistry)).thenReturn(todoDeletedCounter);
+
+ todoService = new TodoService(todoRepository, todoMapper, meterRegistry);
+ todoService.initMetrics();
+ }
+ }
+
+ @Test
+ @DisplayName("Должен успешно создать новую задачу")
+ void add_ShouldCreateTodo_WhenTodoDoesNotExist() {
+ TodoCreateDto createDto = new TodoCreateDto("Test Todo", "Description");
+ Todo todo = Todo.builder()
+ .id(1L)
+ .title("Test Todo")
+ .description("Description")
+ .status(Status.CREATED)
+ .build();
+ TodoDto todoDto = TodoDto.builder()
+ .title("Test Todo")
+ .description("Description")
+ .status(Status.CREATED)
+ .build();
+
+ when(todoRepository.create(any(TodoCreateDto.class))).thenReturn(Optional.of(todo));
+ when(todoMapper.toDto(todo)).thenReturn(todoDto);
+
+ TodoDto result = todoService.add(createDto);
+
+ assertNotNull(result);
+ assertEquals("Test Todo", result.getTitle());
+ assertEquals("Description", result.getDescription());
+ assertEquals(Status.CREATED, result.getStatus());
+ verify(todoRepository, times(1)).create(any(TodoCreateDto.class));
+ verify(todoMapper, times(1)).toDto(todo);
+ verify(todoCreatedCounter, times(1)).increment();
+ }
+
+ @Test
+ @DisplayName("Должен выбросить TodoIsPresentException при попытке создать существующую задачу")
+ void add_ShouldThrowTodoIsPresentException_WhenTodoExists() {
+ TodoCreateDto createDto = new TodoCreateDto("Test Todo", "Description");
+
+ when(todoRepository.create(any(TodoCreateDto.class))).thenReturn(Optional.empty());
+
+ TodoIsPresentException exception = assertThrows(TodoIsPresentException.class,
+ () -> todoService.add(createDto));
+
+ assertEquals("Test Todo is already present", exception.getMessage());
+ verify(todoRepository, times(1)).create(any(TodoCreateDto.class));
+ verify(todoCreatedCounter, never()).increment();
+ }
+
+ @Test
+ @DisplayName("Должен добавить задачу пользователю")
+ void addTodoUser_ShouldAddTodoToUser() {
+ String todoTitle = "Test Todo";
+ String userName = "testuser";
+
+ todoService.addTodoUser(todoTitle, userName);
+
+ verify(todoRepository, times(1)).addTodoToUser(todoTitle, userName);
+ }
+
+ @Test
+ @DisplayName("Должен вернуть список всех задач с пагинацией")
+ void getAll_ShouldReturnListOfTodos() {
+ int limit = 10;
+ int offset = 0;
+ int total = 1;
+ Todo todo = Todo.builder()
+ .id(1L)
+ .title("Test Todo")
+ .description("Description")
+ .status(Status.CREATED)
+ .build();
+ TodoDto todoDto = TodoDto.builder()
+ .title("Test Todo")
+ .description("Description")
+ .status(Status.CREATED)
+ .build();
+ List todos = List.of(todo);
+
+ when(todoRepository.findAll(limit, offset)).thenReturn(todos);
+ when(todoRepository.countAllTodos()).thenReturn(total);
+ when(todoMapper.toDto(todo)).thenReturn(todoDto);
+
+ PageResponse result = todoService.getAll(limit, offset);
+
+ assertNotNull(result);
+ assertEquals(1, result.getContent().size());
+ assertEquals("Test Todo", result.getContent().getFirst().getTitle());
+ assertEquals(total, result.getTotal());
+ assertEquals(limit, result.getLimit());
+ assertEquals(offset, result.getOffset());
+ assertEquals(0, result.getCurrentPage());
+ assertEquals(1, result.getTotalPages());
+ verify(todoRepository, times(1)).findAll(limit, offset);
+ verify(todoRepository, times(1)).countAllTodos();
+ verify(todoMapper, times(1)).toDto(todo);
+ }
+
+ @Test
+ @DisplayName("Должен вернуть список задач пользователя с пагинацией")
+ void getTodoByUserName_ShouldReturnUserTodos() {
+ String userName = "testuser";
+ int limit = 10;
+ int offset = 0;
+ int total = 1;
+ Todo todo = Todo.builder()
+ .id(1L)
+ .title("Test Todo")
+ .description("Description")
+ .status(Status.CREATED)
+ .build();
+ TodoDto todoDto = TodoDto.builder()
+ .title("Test Todo")
+ .description("Description")
+ .status(Status.CREATED)
+ .build();
+ List todos = List.of(todo);
+
+ when(todoRepository.findTodosByUserName(userName, limit, offset)).thenReturn(todos);
+ when(todoRepository.countTodosByUserName(userName)).thenReturn(total);
+ when(todoMapper.toDto(todo)).thenReturn(todoDto);
+
+ PageResponse result = todoService.getTodoByUserName(userName, limit, offset);
+
+ assertNotNull(result);
+ assertEquals(1, result.getContent().size());
+ assertEquals("Test Todo", result.getContent().getFirst().getTitle());
+ assertEquals(total, result.getTotal());
+ assertEquals(limit, result.getLimit());
+ assertEquals(offset, result.getOffset());
+ assertEquals(0, result.getCurrentPage());
+ assertEquals(1, result.getTotalPages());
+ verify(todoRepository, times(1)).findTodosByUserName(userName, limit, offset);
+ verify(todoRepository, times(1)).countTodosByUserName(userName);
+ verify(todoMapper, times(1)).toDto(todo);
+ }
+
+ @Test
+ @DisplayName("Должен успешно удалить задачу у пользователя")
+ void removeTodoFromUser_ShouldRemoveTodoFromUser() {
+ String username = "testuser";
+ String title = "Test Todo";
+
+ when(todoRepository.deleteTodoFromUserByUsername(username, title)).thenReturn(1);
+
+ boolean result = todoService.removeTodoFromUser(username, title);
+
+ assertTrue(result);
+ verify(todoRepository, times(1)).deleteTodoFromUserByUsername(username, title);
+ }
+
+ @Test
+ @DisplayName("Должен выбросить TodoNotFoundException при попытке удалить несуществующую задачу у пользователя")
+ void removeTodoFromUser_ShouldThrowTodoNotFoundException_WhenTodoNotFound() {
+ String username = "testuser";
+ String title = "Nonexistent Todo";
+
+ when(todoRepository.deleteTodoFromUserByUsername(username, title)).thenReturn(0);
+
+ TodoNotFoundException exception = assertThrows(TodoNotFoundException.class,
+ () -> todoService.removeTodoFromUser(username, title));
+
+ assertEquals("Nonexistent Todo not found", exception.getMessage());
+ verify(todoRepository, times(1)).deleteTodoFromUserByUsername(username, title);
+ }
+
+ @Test
+ @DisplayName("Должен успешно обновить статус задачи пользователя")
+ void updateStatusTodoCurrentUser_ShouldUpdateStatus() {
+ String username = "testuser";
+ Status status = Status.IN_PROGRESS;
+ String title = "Test Todo";
+
+ when(todoRepository.updateStatus(username, status, title)).thenReturn(1);
+
+ boolean result = todoService.updateStatusTodoCurrentUser(username, status, title);
+
+ assertTrue(result);
+ verify(todoRepository, times(1)).updateStatus(username, status, title);
+ }
+
+ @Test
+ @DisplayName("Должен выбросить TodoNotFoundException при попытке обновить статус несуществующей задачи")
+ void updateStatusTodoCurrentUser_ShouldThrowTodoNotFoundException_WhenTodoNotFound() {
+ String username = "testuser";
+ Status status = Status.IN_PROGRESS;
+ String title = "Nonexistent Todo";
+
+ when(todoRepository.updateStatus(username, status, title)).thenReturn(0);
+
+ TodoNotFoundException exception = assertThrows(TodoNotFoundException.class,
+ () -> todoService.updateStatusTodoCurrentUser(username, status, title));
+
+ assertEquals("Nonexistent Todo not found", exception.getMessage());
+ verify(todoRepository, times(1)).updateStatus(username, status, title);
+ }
+
+ @Test
+ @DisplayName("Должен успешно удалить задачу по ID")
+ void delete_ShouldDeleteTodo() {
+ Long id = 1L;
+
+ todoService.delete(id);
+
+ verify(todoRepository, times(1)).deleteTodoById(id);
+ verify(todoDeletedCounter, times(1)).increment();
+ }
+
+ @Test
+ @DisplayName("Должен вернуть пустой список при отсутствии задач")
+ void getAll_ShouldReturnEmptyList_WhenNoTodosExist() {
+ int limit = 10;
+ int offset = 0;
+ int total = 0;
+
+ when(todoRepository.findAll(limit, offset)).thenReturn(List.of());
+ when(todoRepository.countAllTodos()).thenReturn(total);
+
+ PageResponse result = todoService.getAll(limit, offset);
+
+ assertNotNull(result);
+ assertTrue(result.getContent().isEmpty());
+ assertEquals(total, result.getTotal());
+ assertEquals(limit, result.getLimit());
+ assertEquals(offset, result.getOffset());
+ assertEquals(0, result.getCurrentPage());
+ assertEquals(0, result.getTotalPages());
+ verify(todoRepository, times(1)).findAll(limit, offset);
+ verify(todoRepository, times(1)).countAllTodos();
+ }
+
+ @Test
+ @DisplayName("Должен вернуть пустой список при отсутствии задач у пользователя")
+ void getTodoByUserName_ShouldReturnEmptyList_WhenNoUserTodosExist() {
+ String userName = "testuser";
+ int limit = 10;
+ int offset = 0;
+ int total = 0;
+
+ when(todoRepository.findTodosByUserName(userName, limit, offset)).thenReturn(List.of());
+ when(todoRepository.countTodosByUserName(userName)).thenReturn(total);
+
+ PageResponse result = todoService.getTodoByUserName(userName, limit, offset);
+
+ assertNotNull(result);
+ assertTrue(result.getContent().isEmpty());
+ assertEquals(total, result.getTotal());
+ assertEquals(limit, result.getLimit());
+ assertEquals(offset, result.getOffset());
+ assertEquals(0, result.getCurrentPage());
+ assertEquals(0, result.getTotalPages());
+ verify(todoRepository, times(1)).findTodosByUserName(userName, limit, offset);
+ verify(todoRepository, times(1)).countTodosByUserName(userName);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/emobile/springtodo/service/UserServiceTest.java b/src/test/java/com/emobile/springtodo/service/UserServiceTest.java
new file mode 100644
index 00000000..bf53f585
--- /dev/null
+++ b/src/test/java/com/emobile/springtodo/service/UserServiceTest.java
@@ -0,0 +1,209 @@
+package com.emobile.springtodo.service;
+
+import com.emobile.springtodo.dto.AuthRequest;
+import com.emobile.springtodo.dto.UserDto;
+import com.emobile.springtodo.enums.Role;
+import com.emobile.springtodo.exception.UserIsPresentException;
+import com.emobile.springtodo.exception.UserNotFoundException;
+import com.emobile.springtodo.mapper.UserMapper;
+import com.emobile.springtodo.model.User;
+import com.emobile.springtodo.repository.UserRepository;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.Gauge;
+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.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+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)
+@DisplayName("Тесты для UserService")
+class UserServiceTest {
+
+ @InjectMocks
+ private UserService userService;
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private PasswordEncoder passwordEncoder;
+
+ @Mock
+ private UserMapper userMapper;
+
+ @Mock
+ private MeterRegistry meterRegistry;
+
+ private Counter userRegisteredCounter;
+ private Counter userDeletedCounter;
+
+ @BeforeEach
+ void setUp() {
+ userRegisteredCounter = mock(Counter.class);
+ userDeletedCounter = mock(Counter.class);
+
+ try (MockedStatic counterStatic = mockStatic(Counter.class);
+ MockedStatic gaugeStatic = mockStatic(Gauge.class)) {
+
+ Counter.Builder registeredCounterBuilder = mock(Counter.Builder.class);
+ Counter.Builder deletedCounterBuilder = mock(Counter.Builder.class);
+ Gauge.Builder gaugeBuilder = mock(Gauge.Builder.class);
+
+ counterStatic.when(() -> Counter.builder("user.registered.total"))
+ .thenReturn(registeredCounterBuilder);
+ when(registeredCounterBuilder.description(anyString())).thenReturn(registeredCounterBuilder);
+ when(registeredCounterBuilder.register(meterRegistry)).thenReturn(userRegisteredCounter);
+
+ counterStatic.when(() -> Counter.builder("user.deleted.total"))
+ .thenReturn(deletedCounterBuilder);
+ when(deletedCounterBuilder.description(anyString())).thenReturn(deletedCounterBuilder);
+ when(deletedCounterBuilder.register(meterRegistry)).thenReturn(userDeletedCounter);
+
+ gaugeStatic.when(() -> Gauge.builder(eq("user.total.current"), any(), any()))
+ .thenReturn(gaugeBuilder);
+ when(gaugeBuilder.description(anyString())).thenReturn(gaugeBuilder);
+ when(gaugeBuilder.register(meterRegistry)).thenReturn(null);
+
+ when(userRepository.findAll()).thenReturn(Collections.emptyList());
+
+ userService = new UserService(userRepository, passwordEncoder, userMapper, meterRegistry);
+ userService.initMetrics();
+ }
+ }
+
+ @Test
+ @DisplayName("Должен успешно сохранить нового пользователя")
+ void save_ShouldSaveUser_WhenUserDoesNotExist() {
+ AuthRequest request = new AuthRequest("testuser", "password");
+ User user = User.builder()
+ .username("testuser")
+ .password("encodedPassword")
+ .roles(List.of(Role.USER))
+ .build();
+ UserDto userDto = new UserDto("testuser");
+
+ when(passwordEncoder.encode("password")).thenReturn("encodedPassword");
+ when(userRepository.save(any(User.class))).thenReturn(Optional.of(user));
+ when(userMapper.toDto(user)).thenReturn(userDto);
+
+ UserDto result = userService.save(request);
+
+ assertNotNull(result);
+ assertEquals("testuser", result.getUsername());
+ verify(passwordEncoder, times(1)).encode("password");
+ verify(userRepository, times(1)).save(any(User.class));
+ verify(userMapper, times(1)).toDto(user);
+ verify(userRegisteredCounter, times(1)).increment();
+ }
+
+ @Test
+ @DisplayName("Должен выбросить UserIsPresentException при попытке сохранить существующего пользователя")
+ void save_ShouldThrowUserIsPresentException_WhenUserExists() {
+ AuthRequest request = new AuthRequest("testuser", "password");
+
+ when(passwordEncoder.encode("password")).thenReturn("encodedPassword");
+ when(userRepository.save(any(User.class))).thenReturn(Optional.empty());
+
+ assertThrows(UserIsPresentException.class, () -> userService.save(request));
+
+ verify(passwordEncoder, times(1)).encode("password");
+ verify(userRepository, times(1)).save(any(User.class));
+ verify(userRegisteredCounter, never()).increment();
+ }
+
+ @Test
+ @DisplayName("Должен вернуть список всех пользователей")
+ void getAllUsers_ShouldReturnListOfUsers() {
+ User user = User.builder()
+ .username("testuser")
+ .password("encodedPassword")
+ .roles(List.of(Role.USER))
+ .build();
+ List users = List.of(user);
+ when(userRepository.findAll()).thenReturn(users);
+
+ List result = userService.getAllUsers();
+
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ assertEquals("testuser", result.getFirst().getUsername());
+ verify(userRepository, times(2)).findAll();
+ }
+
+ @Test
+ @DisplayName("Должен вернуть пустой список, если пользователей нет")
+ void getAllUsers_ShouldReturnEmptyList_WhenNoUsersExist() {
+ when(userRepository.findAll()).thenReturn(Collections.emptyList());
+
+ List result = userService.getAllUsers();
+
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ verify(userRepository, times(2)).findAll();
+ }
+
+ @Test
+ @DisplayName("Должен успешно удалить пользователя по имени")
+ void deleteUser_ShouldDeleteUser_WhenUserExists() {
+ String username = "testuser";
+ when(userRepository.isExistsByUsername(username)).thenReturn(true);
+
+ userService.deleteUser(username);
+
+ verify(userRepository, times(1)).isExistsByUsername(username);
+ verify(userRepository, times(1)).deleteUser(username);
+ verify(userDeletedCounter, times(1)).increment();
+ }
+
+ @Test
+ @DisplayName("Должен выбросить UserNotFoundException при попытке удаления несуществующего пользователя по имени")
+ void deleteUser_ShouldThrowUserNotFoundException_WhenUserDoesNotExist() {
+ String username = "nonexistent";
+ when(userRepository.isExistsByUsername(username)).thenReturn(false);
+
+ assertThrows(UserNotFoundException.class, () -> userService.deleteUser(username));
+ verify(userRepository, times(1)).isExistsByUsername(username);
+ verify(userRepository, never()).deleteUser(username);
+ verify(userDeletedCounter, never()).increment();
+ }
+
+ @Test
+ @DisplayName("Должен успешно удалить пользователя по ID")
+ void deleteUserById_ShouldDeleteUser_WhenUserExists() {
+ Long id = 1L;
+ when(userRepository.isExistsById(id)).thenReturn(true);
+
+ userService.deleteUserById(id);
+
+ verify(userRepository, times(1)).isExistsById(id);
+ verify(userRepository, times(1)).deleteUserById(id);
+ verify(userDeletedCounter, times(1)).increment();
+ }
+
+ @Test
+ @DisplayName("Должен выбросить UserNotFoundException при попытке удаления несуществующего пользователя по ID")
+ void deleteUserById_ShouldThrowUserNotFoundException_WhenUserDoesNotExist() {
+ Long id = 999L;
+ when(userRepository.isExistsById(id)).thenReturn(false);
+
+ assertThrows(UserNotFoundException.class, () -> userService.deleteUserById(id));
+
+ verify(userRepository, times(1)).isExistsById(id);
+ verify(userRepository, never()).deleteUserById(id);
+ verify(userDeletedCounter, never()).increment();
+ }
+}
\ No newline at end of file
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
new file mode 100644
index 00000000..d5092223
--- /dev/null
+++ b/src/test/resources/application-test.yml
@@ -0,0 +1,31 @@
+spring:
+ datasource:
+ url: jdbc:tc:postgresql:15-alpine:///testdb
+ username: test
+ password: test
+ hikari:
+ max-lifetime: 300000
+ maximum-pool-size: 10
+ connection-timeout: 30000
+ idle-timeout: 30000
+ minimum-idle: 2
+
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.PostgreSQLDialect
+ cache:
+ type: none
+
+management:
+ endpoints:
+ access:
+ default: none
+ endpoint:
+ health:
+ access: none
+jwt:
+ secret: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6A7B8C9D0E1F2G3H4
+ expiration: 86400000
\ No newline at end of file
diff --git a/src/test/resources/sql/cleanup.sql b/src/test/resources/sql/cleanup.sql
new file mode 100644
index 00000000..49706dc8
--- /dev/null
+++ b/src/test/resources/sql/cleanup.sql
@@ -0,0 +1,6 @@
+DELETE FROM users_todos;
+DELETE FROM users_roles;
+DELETE FROM todos;
+DELETE FROM users;
+DELETE FROM roles;
+ALTER SEQUENCE users_id_seq RESTART WITH 1;
\ No newline at end of file
diff --git a/src/test/resources/sql/insert-admin-user.sql b/src/test/resources/sql/insert-admin-user.sql
new file mode 100644
index 00000000..4955c7c0
--- /dev/null
+++ b/src/test/resources/sql/insert-admin-user.sql
@@ -0,0 +1,8 @@
+INSERT INTO users (username, password)
+VALUES ('admin', 'adminpass');
+INSERT INTO users_roles (user_id, role_id)
+VALUES ((SELECT id FROM users WHERE username = 'admin'),
+ (SELECT id FROM roles WHERE name = 'ADMIN'));
+INSERT INTO users_roles (user_id, role_id)
+VALUES ((SELECT id FROM users WHERE username = 'admin'),
+ (SELECT id FROM roles WHERE name = 'USER'));
\ No newline at end of file
diff --git a/src/test/resources/sql/insert-base-data.sql b/src/test/resources/sql/insert-base-data.sql
new file mode 100644
index 00000000..35aaf75c
--- /dev/null
+++ b/src/test/resources/sql/insert-base-data.sql
@@ -0,0 +1,5 @@
+INSERT INTO roles (name)
+SELECT 'USER' WHERE NOT EXISTS (SELECT 1 FROM roles WHERE name = 'USER');
+
+INSERT INTO roles (name)
+SELECT 'ADMIN' WHERE NOT EXISTS (SELECT 1 FROM roles WHERE name = 'ADMIN');
\ No newline at end of file
diff --git a/src/test/resources/sql/insert-duplicate-todo.sql b/src/test/resources/sql/insert-duplicate-todo.sql
new file mode 100644
index 00000000..aa121acb
--- /dev/null
+++ b/src/test/resources/sql/insert-duplicate-todo.sql
@@ -0,0 +1,2 @@
+INSERT INTO todos (title, description, status)
+VALUES ('Duplicate Todo', 'Description', 'CREATED');
\ No newline at end of file
diff --git a/src/test/resources/sql/insert-test-todos.sql b/src/test/resources/sql/insert-test-todos.sql
new file mode 100644
index 00000000..063a6790
--- /dev/null
+++ b/src/test/resources/sql/insert-test-todos.sql
@@ -0,0 +1,3 @@
+INSERT INTO todos (title, description, status)
+VALUES ('Test Todo 1', 'Description 1', 'CREATED'),
+ ('Test Todo 2', 'Description 2', 'CREATED');
\ No newline at end of file
diff --git a/src/test/resources/sql/insert-test-user.sql b/src/test/resources/sql/insert-test-user.sql
new file mode 100644
index 00000000..57871c2f
--- /dev/null
+++ b/src/test/resources/sql/insert-test-user.sql
@@ -0,0 +1 @@
+INSERT INTO users (username, password) VALUES ('testuser', 'password');
diff --git a/src/test/resources/sql/insert-todo-and-user.sql b/src/test/resources/sql/insert-todo-and-user.sql
new file mode 100644
index 00000000..e353efb6
--- /dev/null
+++ b/src/test/resources/sql/insert-todo-and-user.sql
@@ -0,0 +1,8 @@
+INSERT INTO users (username, password)
+VALUES ('testuser', 'password');
+INSERT INTO users_roles (user_id, role_id)
+VALUES ((SELECT id FROM users WHERE username = 'testuser'),
+ (SELECT id FROM roles WHERE name = 'USER'));
+
+INSERT INTO todos (title, description, status)
+VALUES ('Test Todo', 'Test Description', 'CREATED');
\ No newline at end of file
diff --git a/src/test/resources/sql/insert-todo-for-deletion.sql b/src/test/resources/sql/insert-todo-for-deletion.sql
new file mode 100644
index 00000000..480c4a17
--- /dev/null
+++ b/src/test/resources/sql/insert-todo-for-deletion.sql
@@ -0,0 +1,2 @@
+INSERT INTO todos (title, description, status)
+VALUES ('Todo to delete', 'Description', 'CREATED');
\ No newline at end of file
diff --git a/src/test/resources/sql/insert-todos.sql b/src/test/resources/sql/insert-todos.sql
new file mode 100644
index 00000000..ad6b56c5
--- /dev/null
+++ b/src/test/resources/sql/insert-todos.sql
@@ -0,0 +1,3 @@
+INSERT INTO todos (title, description, status)
+VALUES ('Todo 1', 'Description 1', 'CREATED'),
+ ('Todo 2', 'Description 2', 'IN_PROGRESS');
\ No newline at end of file
diff --git a/src/test/resources/sql/insert-user-todo-for-removal.sql b/src/test/resources/sql/insert-user-todo-for-removal.sql
new file mode 100644
index 00000000..8fdf4351
--- /dev/null
+++ b/src/test/resources/sql/insert-user-todo-for-removal.sql
@@ -0,0 +1,12 @@
+INSERT INTO users (username, password)
+VALUES ('testuser', 'password');
+INSERT INTO users_roles (user_id, role_id)
+VALUES ((SELECT id FROM users WHERE username = 'testuser'),
+ (SELECT id FROM roles WHERE name = 'USER'));
+
+INSERT INTO todos (title, description, status)
+VALUES ('Todo to remove', 'Description', 'CREATED');
+
+INSERT INTO users_todos (user_id, todo_id)
+VALUES ((SELECT id FROM users WHERE username = 'testuser'),
+ (SELECT id FROM todos WHERE title = 'Todo to remove'));
\ No newline at end of file
diff --git a/src/test/resources/sql/insert-user-todo-for-status.sql b/src/test/resources/sql/insert-user-todo-for-status.sql
new file mode 100644
index 00000000..baf6e50a
--- /dev/null
+++ b/src/test/resources/sql/insert-user-todo-for-status.sql
@@ -0,0 +1,12 @@
+INSERT INTO users (username, password)
+VALUES ('testuser', 'password');
+INSERT INTO users_roles (user_id, role_id)
+VALUES ((SELECT id FROM users WHERE username = 'testuser'),
+ (SELECT id FROM roles WHERE name = 'USER'));
+
+INSERT INTO todos (title, description, status)
+VALUES ('Status Todo', 'Description', 'CREATED');
+
+INSERT INTO users_todos (user_id, todo_id)
+VALUES ((SELECT id FROM users WHERE username = 'testuser'),
+ (SELECT id FROM todos WHERE title = 'Status Todo'));
\ No newline at end of file
diff --git a/src/test/resources/sql/insert-user-todos.sql b/src/test/resources/sql/insert-user-todos.sql
new file mode 100644
index 00000000..56b781bb
--- /dev/null
+++ b/src/test/resources/sql/insert-user-todos.sql
@@ -0,0 +1,12 @@
+INSERT INTO users (username, password)
+VALUES ('testuser', 'password');
+INSERT INTO users_roles (user_id, role_id)
+VALUES ((SELECT id FROM users WHERE username = 'testuser'),
+ (SELECT id FROM roles WHERE name = 'USER'));
+
+INSERT INTO todos (title, description, status)
+VALUES ('User Todo 1', 'User Description 1', 'CREATED');
+
+INSERT INTO users_todos (user_id, todo_id)
+VALUES ((SELECT id FROM users WHERE username = 'testuser'),
+ (SELECT id FROM todos WHERE title = 'User Todo 1'));
\ No newline at end of file
diff --git a/src/test/resources/sql/insert-user.sql b/src/test/resources/sql/insert-user.sql
new file mode 100644
index 00000000..ab7f2a58
--- /dev/null
+++ b/src/test/resources/sql/insert-user.sql
@@ -0,0 +1,5 @@
+INSERT INTO users (username, password)
+VALUES ('testuser', 'password');
+INSERT INTO users_roles (user_id, role_id)
+VALUES ((SELECT id FROM users WHERE username = 'testuser'),
+ (SELECT id FROM roles WHERE name = 'USER'));
\ No newline at end of file
diff --git a/src/test/resources/sql/link-user-todos.sql b/src/test/resources/sql/link-user-todos.sql
new file mode 100644
index 00000000..ca02f291
--- /dev/null
+++ b/src/test/resources/sql/link-user-todos.sql
@@ -0,0 +1,3 @@
+INSERT INTO users_todos (user_id, todo_id) VALUES
+ ((SELECT id FROM users WHERE username = 'testuser'),
+ (SELECT id FROM todos WHERE title = 'Test Todo'));
diff --git a/src/test/resources/sql/schema.sql b/src/test/resources/sql/schema.sql
new file mode 100644
index 00000000..bbd5fbe6
--- /dev/null
+++ b/src/test/resources/sql/schema.sql
@@ -0,0 +1,34 @@
+CREATE TABLE IF NOT EXISTS roles
+(
+ id BIGSERIAL PRIMARY KEY,
+ name VARCHAR(50) UNIQUE NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS users
+(
+ id BIGSERIAL PRIMARY KEY,
+ username VARCHAR(100) UNIQUE NOT NULL,
+ password VARCHAR(255) NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS todos
+(
+ id BIGSERIAL PRIMARY KEY,
+ title VARCHAR(200),
+ description TEXT,
+ status TEXT
+);
+
+CREATE TABLE IF NOT EXISTS users_roles
+(
+ user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ role_id BIGINT NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
+ PRIMARY KEY (user_id, role_id)
+);
+
+CREATE TABLE IF NOT EXISTS users_todos
+(
+ user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ todo_id BIGINT NOT NULL REFERENCES todos (id) ON DELETE CASCADE,
+ PRIMARY KEY (user_id, todo_id)
+);
diff --git a/src/test/resources/sql/test-users.sql b/src/test/resources/sql/test-users.sql
new file mode 100644
index 00000000..db8fcbb5
--- /dev/null
+++ b/src/test/resources/sql/test-users.sql
@@ -0,0 +1,6 @@
+INSERT INTO users (id, username, password)
+VALUES (1, 'admin', '$2a$10$exampleEncodedPassword1'),
+ (2, 'user1', '$2a$10$exampleEncodedPassword2');
+
+INSERT INTO users_roles (user_id, role_id) VALUES (1, 1),
+ (1, 2), (2, 2);
\ No newline at end of file