From 58d2c0c638a9fae1473527e7f345da4110ca7d76 Mon Sep 17 00:00:00 2001 From: Sergey White Date: Tue, 16 Sep 2025 12:59:18 +0300 Subject: [PATCH 1/3] task ready --- .dockerignore | 5 + .env | 12 + Dockerfile | 4 + docker-compose.yaml | 68 +++++ pom.xml | 158 +++++++++- .../springtodo/config/AppInfoContributor.java | 26 ++ .../emobile/springtodo/config/JwtConfig.java | 38 +++ .../springtodo/config/OpenApiConfig.java | 32 ++ .../springtodo/config/RedisConfig.java | 34 +++ .../springtodo/config/SecurityConfig.java | 53 ++++ .../springtodo/controller/AuthController.java | 52 ++++ .../springtodo/controller/TodoController.java | 82 +++++ .../springtodo/controller/UserController.java | 42 +++ .../controller/openApi/AuthApi.java | 45 +++ .../controller/openApi/TodoApi.java | 136 +++++++++ .../controller/openApi/UserApi.java | 60 ++++ .../emobile/springtodo/dto/AuthRequest.java | 24 ++ .../emobile/springtodo/dto/TodoCreateDto.java | 22 ++ .../com/emobile/springtodo/dto/TodoDto.java | 23 ++ .../com/emobile/springtodo/dto/UserDto.java | 13 + .../com/emobile/springtodo/enums/Role.java | 5 + .../com/emobile/springtodo/enums/Status.java | 5 + .../springtodo/exception/AppError.java | 8 + .../exception/GlobalExceptionHandler.java | 74 +++++ .../exception/TodoIsPresentException.java | 7 + .../exception/TodoNotFoundException.java | 7 + .../UnauthorizedActionException.java | 7 + .../exception/UserIsPresentException.java | 7 + .../exception/UserNotFoundException.java | 10 + .../emobile/springtodo/mapper/RowMappers.java | 32 ++ .../emobile/springtodo/mapper/TodoMapper.java | 14 + .../emobile/springtodo/mapper/UserMapper.java | 13 + .../com/emobile/springtodo/model/Todo.java | 19 ++ .../com/emobile/springtodo/model/User.java | 21 ++ .../springtodo/repository/TodoRepository.java | 28 ++ .../springtodo/repository/UserRepository.java | 24 ++ .../repository/impl/TodoRepositoryImpl.java | 109 +++++++ .../repository/impl/UserRepositoryImpl.java | 96 ++++++ .../security/CustomUserDetailsService.java | 32 ++ .../springtodo/security/JwtAuthFilter.java | 60 ++++ .../springtodo/security/JwtService.java | 45 +++ .../springtodo/service/TodoService.java | 112 +++++++ .../springtodo/service/UserService.java | 108 +++++++ src/main/resources/application.properties | 1 - src/main/resources/application.yaml | 47 +++ .../db/changelog/changelog-master.yaml | 5 + .../db/changelog/scripts/create_tables.yaml | 115 +++++++ .../db/changelog/scripts/insert_roles.yaml | 17 ++ .../SpringToDoApplicationTests.java | 5 +- .../controller/AbstractIntegrationTest.java | 43 +++ .../AuthControllerIntegrationTest.java | 76 +++++ .../TodoControllerIntegrationTest.java | 153 ++++++++++ .../UserControllerIntegrationTest.java | 118 ++++++++ .../repository/TodoRepositoryImplTest.java | 275 +++++++++++++++++ .../repository/UserRepositoryImplTest.java | 219 ++++++++++++++ .../springtodo/service/TodoServiceTest.java | 280 ++++++++++++++++++ .../springtodo/service/UserServiceTest.java | 209 +++++++++++++ src/test/resources/application-test.yml | 31 ++ src/test/resources/sql/cleanup.sql | 6 + src/test/resources/sql/insert-admin-user.sql | 8 + src/test/resources/sql/insert-base-data.sql | 5 + .../resources/sql/insert-duplicate-todo.sql | 2 + src/test/resources/sql/insert-test-todos.sql | 3 + src/test/resources/sql/insert-test-user.sql | 1 + .../resources/sql/insert-todo-and-user.sql | 8 + .../sql/insert-todo-for-deletion.sql | 2 + src/test/resources/sql/insert-todos.sql | 3 + .../sql/insert-user-todo-for-removal.sql | 12 + .../sql/insert-user-todo-for-status.sql | 12 + src/test/resources/sql/insert-user-todos.sql | 12 + src/test/resources/sql/insert-user.sql | 5 + src/test/resources/sql/link-user-todos.sql | 3 + src/test/resources/sql/schema.sql | 34 +++ src/test/resources/sql/test-users.sql | 6 + 74 files changed, 3481 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 Dockerfile create mode 100644 docker-compose.yaml create mode 100644 src/main/java/com/emobile/springtodo/config/AppInfoContributor.java create mode 100644 src/main/java/com/emobile/springtodo/config/JwtConfig.java create mode 100644 src/main/java/com/emobile/springtodo/config/OpenApiConfig.java create mode 100644 src/main/java/com/emobile/springtodo/config/RedisConfig.java create mode 100644 src/main/java/com/emobile/springtodo/config/SecurityConfig.java create mode 100644 src/main/java/com/emobile/springtodo/controller/AuthController.java create mode 100644 src/main/java/com/emobile/springtodo/controller/TodoController.java create mode 100644 src/main/java/com/emobile/springtodo/controller/UserController.java create mode 100644 src/main/java/com/emobile/springtodo/controller/openApi/AuthApi.java create mode 100644 src/main/java/com/emobile/springtodo/controller/openApi/TodoApi.java create mode 100644 src/main/java/com/emobile/springtodo/controller/openApi/UserApi.java create mode 100644 src/main/java/com/emobile/springtodo/dto/AuthRequest.java create mode 100644 src/main/java/com/emobile/springtodo/dto/TodoCreateDto.java create mode 100644 src/main/java/com/emobile/springtodo/dto/TodoDto.java create mode 100644 src/main/java/com/emobile/springtodo/dto/UserDto.java create mode 100644 src/main/java/com/emobile/springtodo/enums/Role.java create mode 100644 src/main/java/com/emobile/springtodo/enums/Status.java create mode 100644 src/main/java/com/emobile/springtodo/exception/AppError.java create mode 100644 src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/emobile/springtodo/exception/TodoIsPresentException.java create mode 100644 src/main/java/com/emobile/springtodo/exception/TodoNotFoundException.java create mode 100644 src/main/java/com/emobile/springtodo/exception/UnauthorizedActionException.java create mode 100644 src/main/java/com/emobile/springtodo/exception/UserIsPresentException.java create mode 100644 src/main/java/com/emobile/springtodo/exception/UserNotFoundException.java create mode 100644 src/main/java/com/emobile/springtodo/mapper/RowMappers.java create mode 100644 src/main/java/com/emobile/springtodo/mapper/TodoMapper.java create mode 100644 src/main/java/com/emobile/springtodo/mapper/UserMapper.java create mode 100644 src/main/java/com/emobile/springtodo/model/Todo.java create mode 100644 src/main/java/com/emobile/springtodo/model/User.java create mode 100644 src/main/java/com/emobile/springtodo/repository/TodoRepository.java create mode 100644 src/main/java/com/emobile/springtodo/repository/UserRepository.java create mode 100644 src/main/java/com/emobile/springtodo/repository/impl/TodoRepositoryImpl.java create mode 100644 src/main/java/com/emobile/springtodo/repository/impl/UserRepositoryImpl.java create mode 100644 src/main/java/com/emobile/springtodo/security/CustomUserDetailsService.java create mode 100644 src/main/java/com/emobile/springtodo/security/JwtAuthFilter.java create mode 100644 src/main/java/com/emobile/springtodo/security/JwtService.java create mode 100644 src/main/java/com/emobile/springtodo/service/TodoService.java create mode 100644 src/main/java/com/emobile/springtodo/service/UserService.java delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/db/changelog/changelog-master.yaml create mode 100644 src/main/resources/db/changelog/scripts/create_tables.yaml create mode 100644 src/main/resources/db/changelog/scripts/insert_roles.yaml create mode 100644 src/test/java/com/emobile/springtodo/controller/AbstractIntegrationTest.java create mode 100644 src/test/java/com/emobile/springtodo/controller/AuthControllerIntegrationTest.java create mode 100644 src/test/java/com/emobile/springtodo/controller/TodoControllerIntegrationTest.java create mode 100644 src/test/java/com/emobile/springtodo/controller/UserControllerIntegrationTest.java create mode 100644 src/test/java/com/emobile/springtodo/repository/TodoRepositoryImplTest.java create mode 100644 src/test/java/com/emobile/springtodo/repository/UserRepositoryImplTest.java create mode 100644 src/test/java/com/emobile/springtodo/service/TodoServiceTest.java create mode 100644 src/test/java/com/emobile/springtodo/service/UserServiceTest.java create mode 100644 src/test/resources/application-test.yml create mode 100644 src/test/resources/sql/cleanup.sql create mode 100644 src/test/resources/sql/insert-admin-user.sql create mode 100644 src/test/resources/sql/insert-base-data.sql create mode 100644 src/test/resources/sql/insert-duplicate-todo.sql create mode 100644 src/test/resources/sql/insert-test-todos.sql create mode 100644 src/test/resources/sql/insert-test-user.sql create mode 100644 src/test/resources/sql/insert-todo-and-user.sql create mode 100644 src/test/resources/sql/insert-todo-for-deletion.sql create mode 100644 src/test/resources/sql/insert-todos.sql create mode 100644 src/test/resources/sql/insert-user-todo-for-removal.sql create mode 100644 src/test/resources/sql/insert-user-todo-for-status.sql create mode 100644 src/test/resources/sql/insert-user-todos.sql create mode 100644 src/test/resources/sql/insert-user.sql create mode 100644 src/test/resources/sql/link-user-todos.sql create mode 100644 src/test/resources/sql/schema.sql create mode 100644 src/test/resources/sql/test-users.sql 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/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..22254610 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/AuthController.java @@ -0,0 +1,52 @@ +package com.emobile.springtodo.controller; + + +import com.emobile.springtodo.controller.openApi.AuthApi; +import com.emobile.springtodo.dto.AuthRequest; +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; + +import java.util.Map; + +@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 Map 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); + + return Map.of("token", token); + } + + @Override + @PostMapping("/register") + public UserDto register(@Valid @RequestBody AuthRequest req) { + return userService.save(req); + } + +} + 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..86203aba --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/TodoController.java @@ -0,0 +1,82 @@ +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.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.*; + +import java.util.List; + + +@RequestMapping("/todos") +@RequiredArgsConstructor +@RestController +public class TodoController implements TodoApi { + + private final TodoService service; + + @Override + @PreAuthorize("hasRole('ADMIN')") + @PostMapping + public TodoDto add(@Valid @RequestBody TodoCreateDto dto) { + return service.add(dto); + } + + @Override + @PreAuthorize("hasRole('ADMIN')") + @GetMapping + public List getAllTodos(@RequestParam(defaultValue = "10") + int limit, @RequestParam(defaultValue = "0") int offset) { + return service.getAll(limit, offset); + } + + @Override + @GetMapping("/my") + public List getMyTodos(@AuthenticationPrincipal UserDetails userDetails, @RequestParam(defaultValue = "10") + int limit, @RequestParam(defaultValue = "0") int offset) { + return service.getTodoByUserName(userDetails.getUsername(), limit, offset); + } + + @Override + @PutMapping("/my") + public void createTodoForCurrentUser(@Valid @RequestBody TodoCreateDto dto, @AuthenticationPrincipal UserDetails userDetails) { + service.add(dto); + service.addTodoUser(dto.getTitle(), userDetails.getUsername()); + } + + @Override + @PreAuthorize("hasRole('ADMIN')") + @PutMapping + public void assignTodoToUser(@RequestParam String todoIdTitle, @RequestParam String username) { + service.addTodoUser(todoIdTitle, username); + } + + @Override + @DeleteMapping("/my") + public boolean removeTodoFromUser(@AuthenticationPrincipal UserDetails userDetails, @RequestParam String todoName) { + return service.removeTodoFromUser(userDetails.getUsername(), todoName); + } + + @Override + @PutMapping("/status") + public boolean updateStatus(@AuthenticationPrincipal UserDetails userDetails, @RequestParam Status status, + @RequestParam String title) { + return service.updateStatusTodoCurrentUser(userDetails.getUsername(), status, title); + } + + @Override + @PreAuthorize("hasRole('ADMIN')") + @DeleteMapping("/{id}") + public void delete(@PathVariable Long id) { + service.delete(id); + } + +} + 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..ca52a04e --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/openApi/AuthApi.java @@ -0,0 +1,45 @@ +package com.emobile.springtodo.controller.openApi; + +import com.emobile.springtodo.dto.AuthRequest; +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") + Map 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 = Map.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..f90f5d35 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/openApi/TodoApi.java @@ -0,0 +1,136 @@ +package com.emobile.springtodo.controller.openApi; + +import com.emobile.springtodo.dto.TodoCreateDto; +import com.emobile.springtodo.dto.TodoDto; +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.List; +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 add(@RequestBody TodoCreateDto dto); + + @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))) + }) + @GetMapping + List 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 = TodoDto.class))), + @ApiResponse(responseCode = "401", description = "Не авторизован", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = Map.class))) + }) + @GetMapping("/my") + List getMyTodos(@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 = "Задача успешно создана"), + @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))) + }) + @PutMapping("/my") + void 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))) + }) + @PutMapping + void assignTodoToUser(@Parameter(description = "Название задачи") @RequestParam String todoIdTitle, + @Parameter(description = "Имя пользователя") @RequestParam 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))) + }) + @DeleteMapping("/my") + boolean removeTodoFromUser(@AuthenticationPrincipal UserDetails userDetails, + @Parameter(description = "Название задачи") @RequestParam 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))) + }) + @PutMapping("/status") + boolean updateStatus(@AuthenticationPrincipal UserDetails userDetails, + @Parameter(description = "Статус задачи") @RequestParam Status status, + @Parameter(description = "Название задачи") @RequestParam 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 delete(@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/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..4633a16e --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/TodoRepository.java @@ -0,0 +1,28 @@ +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); +} 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..330fa1e3 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/impl/TodoRepositoryImpl.java @@ -0,0 +1,109 @@ +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 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..0fd40136 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/security/JwtService.java @@ -0,0 +1,45 @@ +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(); + } +} 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..1a4790c4 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/TodoService.java @@ -0,0 +1,112 @@ +package com.emobile.springtodo.service; + +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 List getAll(int limit, int offset) { + return repository + .findAll(limit,offset) + .stream() + .map(mapper::toDto) + .toList(); + } + + @Cacheable(key = "'todos_user_' + #userName + '_limit_' + #limit + '_offset_' + #offset") + @Transactional(readOnly = true) + public List getTodoByUserName(String userName, int limit, int offset) { + return repository + .findTodosByUserName(userName,limit,offset) + .stream() + .map(mapper::toDto) + .toList(); + } + + @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(); + } +} \ 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..906b8955 --- /dev/null +++ b/src/main/resources/db/changelog/changelog-master.yaml @@ -0,0 +1,5 @@ +databaseChangeLog: + - include: + file: db/changelog/scripts/create_tables.yaml + - include: + file: db/changelog/scripts/insert_roles.yaml diff --git a/src/main/resources/db/changelog/scripts/create_tables.yaml b/src/main/resources/db/changelog/scripts/create_tables.yaml new file mode 100644 index 00000000..26b963d1 --- /dev/null +++ b/src/main/resources/db/changelog/scripts/create_tables.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/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/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..528777f8 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/controller/TodoControllerIntegrationTest.java @@ -0,0 +1,153 @@ +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/my") + .with(user("testuser").password("password").roles("USER")) + .param("todoName", "Todo to remove")) + .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/my") + .with(user("testuser").password("password").roles("USER")) + .param("todoName", "NonExistentTodo")) + .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(put("/todos/status") + .with(user("testuser").password("password").roles("USER")) + .param("status", "CREATED") + .param("title", "Status Todo")) + .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(put("/todos/status") + .with(user("testuser").password("password").roles("USER")) + .param("status", "CREATED") + .param("title", "NonExistentTodo")) + .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..f6e47540 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/service/TodoServiceTest.java @@ -0,0 +1,280 @@ +package com.emobile.springtodo.service; + +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() { + 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(10, 0)).thenReturn(todos); + when(todoMapper.toDto(todo)).thenReturn(todoDto); + + List result = todoService.getAll(10, 0); + + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("Test Todo", result.getFirst().getTitle()); + verify(todoRepository, times(1)).findAll(10, 0); + verify(todoMapper, times(1)).toDto(todo); + } + + @Test + @DisplayName("Должен вернуть список задач пользователя с пагинацией") + void getTodoByUserName_ShouldReturnUserTodos() { + String userName = "testuser"; + 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, 10, 0)).thenReturn(todos); + when(todoMapper.toDto(todo)).thenReturn(todoDto); + + List result = todoService.getTodoByUserName(userName, 10, 0); + + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("Test Todo", result.getFirst().getTitle()); + verify(todoRepository, times(1)).findTodosByUserName(userName, 10, 0); + 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() { + when(todoRepository.findAll(10, 0)).thenReturn(List.of()); + + List result = todoService.getAll(10, 0); + + assertNotNull(result); + assertTrue(result.isEmpty()); + verify(todoRepository, times(1)).findAll(10, 0); + } + + @Test + @DisplayName("Должен вернуть пустой список при отсутствии задач у пользователя") + void getTodoByUserName_ShouldReturnEmptyList_WhenNoUserTodosExist() { + String userName = "testuser"; + when(todoRepository.findTodosByUserName(userName, 10, 0)).thenReturn(List.of()); + + List result = todoService.getTodoByUserName(userName, 10, 0); + + assertNotNull(result); + assertTrue(result.isEmpty()); + verify(todoRepository, times(1)).findTodosByUserName(userName, 10, 0); + } +} \ 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 From e7e2ac4d8771feb3da695d2a0f073828779017e3 Mon Sep 17 00:00:00 2001 From: Sergey White Date: Tue, 16 Sep 2025 18:19:46 +0300 Subject: [PATCH 2/3] fix 1 --- .../springtodo/controller/AuthController.java | 15 ++-- .../springtodo/controller/TodoController.java | 51 ++++++------ .../controller/openApi/AuthApi.java | 5 +- .../controller/openApi/TodoApi.java | 61 ++++++++------ .../emobile/springtodo/dto/AuthResponse.java | 24 ++++++ .../emobile/springtodo/dto/PageResponse.java | 23 ++++++ .../springtodo/repository/TodoRepository.java | 5 ++ .../repository/impl/TodoRepositoryImpl.java | 14 ++++ .../springtodo/security/JwtService.java | 10 ++- .../springtodo/service/TodoService.java | 39 +++++++-- .../db/changelog/changelog-master.yaml | 2 +- ...{create_tables.yaml => preconditions.yaml} | 0 .../TodoControllerIntegrationTest.java | 20 ++--- .../springtodo/service/TodoServiceTest.java | 79 ++++++++++++++----- 14 files changed, 246 insertions(+), 102 deletions(-) create mode 100644 src/main/java/com/emobile/springtodo/dto/AuthResponse.java create mode 100644 src/main/java/com/emobile/springtodo/dto/PageResponse.java rename src/main/resources/db/changelog/scripts/{create_tables.yaml => preconditions.yaml} (100%) diff --git a/src/main/java/com/emobile/springtodo/controller/AuthController.java b/src/main/java/com/emobile/springtodo/controller/AuthController.java index 22254610..a38007d1 100644 --- a/src/main/java/com/emobile/springtodo/controller/AuthController.java +++ b/src/main/java/com/emobile/springtodo/controller/AuthController.java @@ -1,8 +1,8 @@ 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; @@ -17,8 +17,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; - @RestController @RequestMapping("/auth") @RequiredArgsConstructor @@ -28,10 +26,9 @@ public class AuthController implements AuthApi { private final JwtService jwtService; private final UserService userService; - @Override @PostMapping("/login") - public Map login(@Valid @RequestBody AuthRequest request) { + public AuthResponse login(@Valid @RequestBody AuthRequest request) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) ); @@ -39,7 +36,9 @@ public Map login(@Valid @RequestBody AuthRequest request) { UserDetails userDetails = (UserDetails) authentication.getPrincipal(); String token = jwtService.generateToken(userDetails); - return Map.of("token", token); + Long expiresIn = jwtService.getExpirationTime() / 1000; + + return new AuthResponse(token, "Bearer", expiresIn); } @Override @@ -47,6 +46,4 @@ public Map login(@Valid @RequestBody AuthRequest request) { 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 index 86203aba..b4f54e68 100644 --- a/src/main/java/com/emobile/springtodo/controller/TodoController.java +++ b/src/main/java/com/emobile/springtodo/controller/TodoController.java @@ -3,6 +3,7 @@ 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; @@ -12,9 +13,6 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.*; -import java.util.List; - - @RequestMapping("/todos") @RequiredArgsConstructor @RestController @@ -25,58 +23,61 @@ public class TodoController implements TodoApi { @Override @PreAuthorize("hasRole('ADMIN')") @PostMapping - public TodoDto add(@Valid @RequestBody TodoCreateDto dto) { + public TodoDto createTodo(@Valid @RequestBody TodoCreateDto dto) { return service.add(dto); } @Override @PreAuthorize("hasRole('ADMIN')") @GetMapping - public List getAllTodos(@RequestParam(defaultValue = "10") - int limit, @RequestParam(defaultValue = "0") int offset) { + public PageResponse getAllTodos(@RequestParam(defaultValue = "10") int limit, + @RequestParam(defaultValue = "0") int offset) { return service.getAll(limit, offset); } @Override - @GetMapping("/my") - public List getMyTodos(@AuthenticationPrincipal UserDetails userDetails, @RequestParam(defaultValue = "10") - int limit, @RequestParam(defaultValue = "0") int offset) { + @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 - @PutMapping("/my") - public void createTodoForCurrentUser(@Valid @RequestBody TodoCreateDto dto, @AuthenticationPrincipal UserDetails userDetails) { - service.add(dto); - service.addTodoUser(dto.getTitle(), userDetails.getUsername()); + @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')") - @PutMapping - public void assignTodoToUser(@RequestParam String todoIdTitle, @RequestParam String username) { - service.addTodoUser(todoIdTitle, username); + @PostMapping("/{todoId}/assign/{username}") + public void assignTodoToUser(@PathVariable String todoId, @PathVariable String username) { + service.addTodoUser(todoId, username); } @Override - @DeleteMapping("/my") - public boolean removeTodoFromUser(@AuthenticationPrincipal UserDetails userDetails, @RequestParam String todoName) { + @DeleteMapping("/current-user/{todoName}") + public boolean removeTodoFromCurrentUser(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable String todoName) { return service.removeTodoFromUser(userDetails.getUsername(), todoName); } @Override - @PutMapping("/status") - public boolean updateStatus(@AuthenticationPrincipal UserDetails userDetails, @RequestParam Status status, - @RequestParam String title) { + @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 delete(@PathVariable Long 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/openApi/AuthApi.java b/src/main/java/com/emobile/springtodo/controller/openApi/AuthApi.java index ca52a04e..4b12a9e1 100644 --- a/src/main/java/com/emobile/springtodo/controller/openApi/AuthApi.java +++ b/src/main/java/com/emobile/springtodo/controller/openApi/AuthApi.java @@ -1,6 +1,7 @@ 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; @@ -29,7 +30,7 @@ public interface AuthApi { schema = @Schema(implementation = Map.class))) }) @PostMapping("/login") - Map login(@RequestBody AuthRequest request); + AuthResponse login(@RequestBody AuthRequest request); @Operation(summary = "Регистрация пользователя", description = "Создание нового пользователя") @ApiResponses(value = { @@ -38,7 +39,7 @@ public interface AuthApi { schema = @Schema(implementation = UserDto.class))), @ApiResponse(responseCode = "400", description = "Пользователь уже существует или неверный формат", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = Map.class))) + schema = @Schema(implementation = AuthResponse.class))) }) @PostMapping("/register") UserDto register(@RequestBody AuthRequest request); diff --git a/src/main/java/com/emobile/springtodo/controller/openApi/TodoApi.java b/src/main/java/com/emobile/springtodo/controller/openApi/TodoApi.java index f90f5d35..839cb5a1 100644 --- a/src/main/java/com/emobile/springtodo/controller/openApi/TodoApi.java +++ b/src/main/java/com/emobile/springtodo/controller/openApi/TodoApi.java @@ -2,6 +2,7 @@ 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; @@ -15,7 +16,6 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.*; -import java.util.List; import java.util.Map; @Tag(name = "Todos", description = "API для управления задачами") @@ -35,38 +35,40 @@ public interface TodoApi { schema = @Schema(implementation = Map.class))) }) @PostMapping - TodoDto add(@RequestBody TodoCreateDto dto); + TodoDto createTodo(@RequestBody TodoCreateDto dto); @Operation(summary = "Получить все задачи", description = "Получение списка всех задач с пагинацией (только для ADMIN)") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Список задач успешно получен", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = TodoDto.class))), + schema = @Schema(implementation = PageResponse.class))), @ApiResponse(responseCode = "403", description = "Доступ запрещен", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Map.class))) }) @GetMapping - List getAllTodos(@Parameter(description = "Лимит записей") @RequestParam(defaultValue = "10") int limit, - @Parameter(description = "Смещение") @RequestParam(defaultValue = "0") int offset); + 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 = TodoDto.class))), + schema = @Schema(implementation = PageResponse.class))), @ApiResponse(responseCode = "401", description = "Не авторизован", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Map.class))) }) - @GetMapping("/my") - List getMyTodos(@AuthenticationPrincipal UserDetails userDetails, - @Parameter(description = "Лимит записей") @RequestParam(defaultValue = "10") int limit, - @Parameter(description = "Смещение") @RequestParam(defaultValue = "0") int offset); + @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 = "Задача успешно создана"), + @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))), @@ -74,22 +76,25 @@ List getMyTodos(@AuthenticationPrincipal UserDetails userDetails, content = @Content(mediaType = "application/json", schema = @Schema(implementation = Map.class))) }) - @PutMapping("/my") - void createTodoForCurrentUser(@RequestBody TodoCreateDto dto, @AuthenticationPrincipal UserDetails userDetails); + @PostMapping("/current-user") + TodoDto createTodoForCurrentUser(@RequestBody TodoCreateDto dto, @AuthenticationPrincipal UserDetails userDetails); - @Operation(summary = "Назначить задачу пользователю", description = "Привязка задачи к пользователю по имени (только для ADMIN)") + @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))) }) - @PutMapping - void assignTodoToUser(@Parameter(description = "Название задачи") @RequestParam String todoIdTitle, - @Parameter(description = "Имя пользователя") @RequestParam String username); + @PostMapping("/{todoId}/assign/{username}") + void assignTodoToUser(@Parameter(description = "ID задачи") @PathVariable String todoId, + @Parameter(description = "Имя пользователя") @PathVariable String username); @Operation(summary = "Удалить задачу текущего пользователя", description = "Удаление задачи из списка задач текущего пользователя") @ApiResponses(value = { @@ -97,12 +102,15 @@ void assignTodoToUser(@Parameter(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("/my") - boolean removeTodoFromUser(@AuthenticationPrincipal UserDetails userDetails, - @Parameter(description = "Название задачи") @RequestParam String todoName); + @DeleteMapping("/current-user/{todoName}") + boolean removeTodoFromCurrentUser(@AuthenticationPrincipal UserDetails userDetails, + @Parameter(description = "Название задачи") @PathVariable String todoName); @Operation(summary = "Обновить статус задачи", description = "Обновление статуса задачи текущего пользователя") @ApiResponses(value = { @@ -113,13 +121,16 @@ boolean removeTodoFromUser(@AuthenticationPrincipal UserDetails userDetails, 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))) }) - @PutMapping("/status") - boolean updateStatus(@AuthenticationPrincipal UserDetails userDetails, - @Parameter(description = "Статус задачи") @RequestParam Status status, - @Parameter(description = "Название задачи") @RequestParam String title); + @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 = { @@ -132,5 +143,5 @@ boolean updateStatus(@AuthenticationPrincipal UserDetails userDetails, schema = @Schema(implementation = Map.class))) }) @DeleteMapping("/{id}") - void delete(@Parameter(description = "ID задачи") @PathVariable Long id); + void deleteTodo(@Parameter(description = "ID задачи") @PathVariable Long id); } \ No newline at end of file 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/repository/TodoRepository.java b/src/main/java/com/emobile/springtodo/repository/TodoRepository.java index 4633a16e..f8400536 100644 --- a/src/main/java/com/emobile/springtodo/repository/TodoRepository.java +++ b/src/main/java/com/emobile/springtodo/repository/TodoRepository.java @@ -25,4 +25,9 @@ public interface TodoRepository { 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/impl/TodoRepositoryImpl.java b/src/main/java/com/emobile/springtodo/repository/impl/TodoRepositoryImpl.java index 330fa1e3..e4e27f5b 100644 --- a/src/main/java/com/emobile/springtodo/repository/impl/TodoRepositoryImpl.java +++ b/src/main/java/com/emobile/springtodo/repository/impl/TodoRepositoryImpl.java @@ -100,6 +100,20 @@ public int updateStatus(String username, Status status, String todoTitle) { 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 = ?"; diff --git a/src/main/java/com/emobile/springtodo/security/JwtService.java b/src/main/java/com/emobile/springtodo/security/JwtService.java index 0fd40136..d758036e 100644 --- a/src/main/java/com/emobile/springtodo/security/JwtService.java +++ b/src/main/java/com/emobile/springtodo/security/JwtService.java @@ -1,17 +1,14 @@ 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 { @@ -42,4 +39,9 @@ public String extractUsername(String 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 index 1a4790c4..0251366b 100644 --- a/src/main/java/com/emobile/springtodo/service/TodoService.java +++ b/src/main/java/com/emobile/springtodo/service/TodoService.java @@ -1,5 +1,6 @@ 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; @@ -62,22 +63,30 @@ public void addTodoUser(String todoTitle, String userName) { @Cacheable(key = "'all_todos_limit_' + #limit + '_offset_' + #offset") @Transactional(readOnly = true) - public List getAll(int limit, int offset) { - return repository - .findAll(limit,offset) + 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 List getTodoByUserName(String userName, int limit, int offset) { - return repository - .findTodosByUserName(userName,limit,offset) + 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 = { @@ -109,4 +118,22 @@ 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/resources/db/changelog/changelog-master.yaml b/src/main/resources/db/changelog/changelog-master.yaml index 906b8955..95768114 100644 --- a/src/main/resources/db/changelog/changelog-master.yaml +++ b/src/main/resources/db/changelog/changelog-master.yaml @@ -1,5 +1,5 @@ databaseChangeLog: - include: - file: db/changelog/scripts/create_tables.yaml + file: db/changelog/scripts/preconditions.yaml - include: file: db/changelog/scripts/insert_roles.yaml diff --git a/src/main/resources/db/changelog/scripts/create_tables.yaml b/src/main/resources/db/changelog/scripts/preconditions.yaml similarity index 100% rename from src/main/resources/db/changelog/scripts/create_tables.yaml rename to src/main/resources/db/changelog/scripts/preconditions.yaml diff --git a/src/test/java/com/emobile/springtodo/controller/TodoControllerIntegrationTest.java b/src/test/java/com/emobile/springtodo/controller/TodoControllerIntegrationTest.java index 528777f8..476c4d6c 100644 --- a/src/test/java/com/emobile/springtodo/controller/TodoControllerIntegrationTest.java +++ b/src/test/java/com/emobile/springtodo/controller/TodoControllerIntegrationTest.java @@ -77,9 +77,8 @@ void shouldReturnForbiddenWhenNonAdminCreatesTodo() throws Exception { @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/my") - .with(user("testuser").password("password").roles("USER")) - .param("todoName", "Todo to remove")) + String result = mockMvc.perform(delete("/todos/current-user/{todoName}", "Todo to remove") + .with(user("testuser").password("password").roles("USER"))) .andExpect(status().isOk()) .andReturn() .getResponse() @@ -92,9 +91,8 @@ void shouldRemoveTodoFromUser() throws Exception { @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/my") - .with(user("testuser").password("password").roles("USER")) - .param("todoName", "NonExistentTodo")) + MvcResult result = mockMvc.perform(delete("/todos/current-user/{todoName}", "NonExistentTodo") + .with(user("testuser").password("password").roles("USER"))) .andExpect(status().isNotFound()) .andReturn(); @@ -106,10 +104,9 @@ void shouldReturnNotFoundWhenRemovingNonExistentTodo() throws Exception { @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(put("/todos/status") + String result = mockMvc.perform(patch("/todos/current-user/{title}/status", "Status Todo") .with(user("testuser").password("password").roles("USER")) - .param("status", "CREATED") - .param("title", "Status Todo")) + .param("status", "CREATED")) .andExpect(status().isOk()) .andReturn() .getResponse() @@ -122,10 +119,9 @@ void shouldUpdateTodoStatus() throws Exception { @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(put("/todos/status") + MvcResult result = mockMvc.perform(patch("/todos/current-user/{title}/status", "NonExistentTodo") .with(user("testuser").password("password").roles("USER")) - .param("status", "CREATED") - .param("title", "NonExistentTodo")) + .param("status", "CREATED")) .andExpect(status().isNotFound()) .andReturn(); diff --git a/src/test/java/com/emobile/springtodo/service/TodoServiceTest.java b/src/test/java/com/emobile/springtodo/service/TodoServiceTest.java index f6e47540..baab03fa 100644 --- a/src/test/java/com/emobile/springtodo/service/TodoServiceTest.java +++ b/src/test/java/com/emobile/springtodo/service/TodoServiceTest.java @@ -1,5 +1,6 @@ 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; @@ -128,6 +129,9 @@ void addTodoUser_ShouldAddTodoToUser() { @Test @DisplayName("Должен вернуть список всех задач с пагинацией") void getAll_ShouldReturnListOfTodos() { + int limit = 10; + int offset = 0; + int total = 1; Todo todo = Todo.builder() .id(1L) .title("Test Todo") @@ -141,15 +145,22 @@ void getAll_ShouldReturnListOfTodos() { .build(); List todos = List.of(todo); - when(todoRepository.findAll(10, 0)).thenReturn(todos); + when(todoRepository.findAll(limit, offset)).thenReturn(todos); + when(todoRepository.countAllTodos()).thenReturn(total); when(todoMapper.toDto(todo)).thenReturn(todoDto); - List result = todoService.getAll(10, 0); + PageResponse result = todoService.getAll(limit, offset); assertNotNull(result); - assertEquals(1, result.size()); - assertEquals("Test Todo", result.getFirst().getTitle()); - verify(todoRepository, times(1)).findAll(10, 0); + 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); } @@ -157,6 +168,9 @@ void getAll_ShouldReturnListOfTodos() { @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") @@ -170,15 +184,22 @@ void getTodoByUserName_ShouldReturnUserTodos() { .build(); List todos = List.of(todo); - when(todoRepository.findTodosByUserName(userName, 10, 0)).thenReturn(todos); + when(todoRepository.findTodosByUserName(userName, limit, offset)).thenReturn(todos); + when(todoRepository.countTodosByUserName(userName)).thenReturn(total); when(todoMapper.toDto(todo)).thenReturn(todoDto); - List result = todoService.getTodoByUserName(userName, 10, 0); + PageResponse result = todoService.getTodoByUserName(userName, limit, offset); assertNotNull(result); - assertEquals(1, result.size()); - assertEquals("Test Todo", result.getFirst().getTitle()); - verify(todoRepository, times(1)).findTodosByUserName(userName, 10, 0); + 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); } @@ -256,25 +277,47 @@ void delete_ShouldDeleteTodo() { @Test @DisplayName("Должен вернуть пустой список при отсутствии задач") void getAll_ShouldReturnEmptyList_WhenNoTodosExist() { - when(todoRepository.findAll(10, 0)).thenReturn(List.of()); + int limit = 10; + int offset = 0; + int total = 0; - List result = todoService.getAll(10, 0); + when(todoRepository.findAll(limit, offset)).thenReturn(List.of()); + when(todoRepository.countAllTodos()).thenReturn(total); + + PageResponse result = todoService.getAll(limit, offset); assertNotNull(result); - assertTrue(result.isEmpty()); - verify(todoRepository, times(1)).findAll(10, 0); + 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"; - when(todoRepository.findTodosByUserName(userName, 10, 0)).thenReturn(List.of()); + int limit = 10; + int offset = 0; + int total = 0; + + when(todoRepository.findTodosByUserName(userName, limit, offset)).thenReturn(List.of()); + when(todoRepository.countTodosByUserName(userName)).thenReturn(total); - List result = todoService.getTodoByUserName(userName, 10, 0); + PageResponse result = todoService.getTodoByUserName(userName, limit, offset); assertNotNull(result); - assertTrue(result.isEmpty()); - verify(todoRepository, times(1)).findTodosByUserName(userName, 10, 0); + 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 From 87fc04383102323ef82e8345e03df295b51f46ab Mon Sep 17 00:00:00 2001 From: Sergey White Date: Sun, 12 Oct 2025 16:18:00 +0300 Subject: [PATCH 3/3] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20GitLab=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .gitlab-ci.yml 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