diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..c0ba9be4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM openjdk:17-oracle +WORKDIR /app +COPY . /app +COPY target/SpringToDo-0.0.1-SNAPSHOT.jar app.jar +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/HowToUse b/HowToUse new file mode 100644 index 00000000..04d8bbe4 --- /dev/null +++ b/HowToUse @@ -0,0 +1,3 @@ +mvn clean install +docker build -t spring-to-do . +docker compose up diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..7259d88a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3' +services: + spring-to-do: + image: spring-to-do + depends_on: + - db + - redis + environment: + - "SERVER_URL=jdbc:postgresql://db:5432/spring_todo_db" + - "SERVER_USERNAME=user" + - "SERVER_PASS=pass" + - "REDIS_HOST=redis" + - "REDIS_PORT=6379" + - "FLYWAY_ENABLED=true" + - "REDIS_ENABLED=true" + ports: + - "8088:8088" + db: + image: postgres:latest + container_name: db + restart: always + environment: + - POSTGRES_USER=user + - POSTGRES_PASSWORD=pass + - POSTGRES_DB=spring_todo_db + ports: + - "5432:5432" + redis: + container_name: redis + image: redis:7.0.12 + ports: + - "6379:6379" \ No newline at end of file diff --git a/pom.xml b/pom.xml index 03bc446d..e823d390 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.3.5 + 3.4.0 com.emobile @@ -15,7 +15,12 @@ SpringToDo - 21 + 17 + 1.5.5.Final + 1.18.34 + 5.7.1 + 3.6.0 + 2.7.0 @@ -23,12 +28,100 @@ org.springframework.boot spring-boot-starter + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.postgresql + postgresql + runtime + + + org.hibernate.validator + hibernate-validator + 8.0.0.Final + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + 11.3.3 + + + + org.springframework.boot + spring-boot-starter-cache + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.mapstruct + mapstruct + ${org.mapstruct.version} + + + org.projectlombok + lombok + true + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${org.springdoc-openapi.version} + org.springframework.boot spring-boot-starter-test test + + org.springframework.security + spring-security-test + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + + + com.redis.testcontainers + testcontainers-redis-junit + 1.6.4 + test + @@ -37,7 +130,67 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.0 + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${org.projectlombok.version} + + + org.mapstruct + mapstruct-processor + ${org.mapstruct.version} + + + + -Amapstruct.suppressGeneratorTimestamp=true + -Amapstruct.defaultComponentModel=spring + + + + + org.jacoco + jacoco-maven-plugin + 0.8.11 + + + + coverage + + true + + + + + org.jacoco + jacoco-maven-plugin + + + prepare-agent + + prepare-agent + + + + report + + report + + + + + + + + diff --git a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java index 41980514..adc7ee6d 100644 --- a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java +++ b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java @@ -5,7 +5,6 @@ @SpringBootApplication public class SpringToDoApplication { - public static void main(String[] args) { SpringApplication.run(SpringToDoApplication.class, args); } diff --git a/src/main/java/com/emobile/springtodo/aop/LazyLogger.java b/src/main/java/com/emobile/springtodo/aop/LazyLogger.java new file mode 100644 index 00000000..4f5e8a6d --- /dev/null +++ b/src/main/java/com/emobile/springtodo/aop/LazyLogger.java @@ -0,0 +1,11 @@ +package com.emobile.springtodo.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface LazyLogger { +} diff --git a/src/main/java/com/emobile/springtodo/aop/LazyLoggerAdvice.java b/src/main/java/com/emobile/springtodo/aop/LazyLoggerAdvice.java new file mode 100644 index 00000000..c1e94884 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/aop/LazyLoggerAdvice.java @@ -0,0 +1,94 @@ +package com.emobile.springtodo.aop; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.AfterThrowing; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.reflect.CodeSignature; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * {@link LazyLogger} Advice. + */ +@Aspect +@Component +@Slf4j +public class LazyLoggerAdvice { + /** + * true - {@link #printErrorMessage(String)} use slf4j. + * false - {@link #printErrorMessage(String)} use s.out. + */ + @Value("${app.logger.slf4j}") + private boolean slf4jLog; + + @Before(value = "@annotation(LazyLogger)") + public void beforeLazyLogger(JoinPoint joinPoint) { + String loggerMessage = getLoggerMessage(joinPoint, "Before "); + printInfoMessage(loggerMessage); + } + + @AfterThrowing(value = "@annotation(LazyLogger)") + public void afterLazyLogger(JoinPoint joinPoint) { + String loggerMessage = getLoggerMessage(joinPoint, "AfterThrowing "); + printErrorMessage(loggerMessage); + } + + @AfterReturning(value = "@annotation(LazyLogger)", returning = "result") + public void afterReturnLazyLogger(JoinPoint joinPoint, Object result) { + String loggerMessage = getLoggerMessage(joinPoint, "AfterReturning ", result); + printInfoMessage(loggerMessage); + } + + private String getLoggerMessage(JoinPoint joinPoint, String type) { + CodeSignature codeSignature = (CodeSignature) joinPoint.getSignature(); + StringBuilder sb = new StringBuilder(); + + sb.append(type) + .append(joinPoint.getTarget().getClass().getSimpleName()) + .append(".") + .append(codeSignature.getName()) + .append("("); + + for (int i = 0; i < joinPoint.getArgs().length; i++) { + if (i != 0) { + sb.append(","); + } + sb.append(codeSignature.getParameterNames()[i]) + .append(" = ") + .append(joinPoint.getArgs()[i]); + } + sb.append(")"); + return sb.toString(); + } + + private String getLoggerMessage(JoinPoint joinPoint, String type, Object result) { + StringBuilder sb = new StringBuilder(getLoggerMessage(joinPoint, type)); + + if (result != null) { + sb.append(" with value : ") + .append(result); + } else { + sb.append(" with null as return value"); + } + return sb.toString(); + } + + private void printInfoMessage(String loggerMessage) { + if (slf4jLog) { + log.info(loggerMessage); + } else { + System.out.println(loggerMessage); + } + } + + private void printErrorMessage(String loggerMessage) { + if (slf4jLog) { + log.error(loggerMessage); + } else { + System.err.println(loggerMessage); + } + } +} diff --git a/src/main/java/com/emobile/springtodo/config/AppConfig.java b/src/main/java/com/emobile/springtodo/config/AppConfig.java new file mode 100644 index 00000000..d5f1ebc2 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/AppConfig.java @@ -0,0 +1,65 @@ +package com.emobile.springtodo.config; + +import com.emobile.springtodo.config.property.cache.AppCacheProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import javax.sql.DataSource; +import java.time.Clock; + +/** + * Default application configuration. + */ +@Configuration +@ComponentScan("com.emobile") +@EnableWebMvc +@EnableCaching +@EnableConfigurationProperties(AppCacheProperties.class) +@PropertySource("classpath:config/application.yml") +public class AppConfig { + @Value("${spring.datasource.driver-class-name}") + private String dataSourceDriverClassName; + @Value("${spring.datasource.url}") + private String dataSourceUrl; + @Value("${spring.datasource.username}") + private String dataSourceUsername; + @Value("${spring.datasource.password}") + private String dataSourcePassword; + + @Bean + public DataSource dataSource() { + DriverManagerDataSource dataSource = new DriverManagerDataSource(); + + dataSource.setDriverClassName(dataSourceDriverClassName); + dataSource.setUrl(dataSourceUrl); + dataSource.setUsername(dataSourceUsername); + dataSource.setPassword(dataSourcePassword); + + return dataSource; + } + + @Bean + public JdbcTemplate jdbcTemplate(DataSource dataSource) { + return new JdbcTemplate(dataSource); + } + + @Bean + public PlatformTransactionManager txManager() { + return new DataSourceTransactionManager(dataSource()); + } + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } +} 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..f47703ad --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/OpenAPIConfig.java @@ -0,0 +1,52 @@ +package com.emobile.springtodo.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * OpenAPI configuration for working with SwaggerUI. + */ +@Configuration +@RequiredArgsConstructor +public class OpenAPIConfig { + /** + * Server URL in Development environment. + */ + @Value("${app.openapi.dev-url}") + private String devUrl; + + /** + * Initiation OpenAPI bean for working with SwaggerUI. + * + * @return {@link OpenAPI} with updated parameters. + */ + @Bean + public OpenAPI myOpenAPI() { + Server devServer = new Server(); + devServer.setUrl(devUrl); + devServer.setDescription("Server URL in Development environment."); + + Contact contact = new Contact(); + contact.setEmail("aa.sattarov@gmail.com"); + contact.setName("Alexey Sattarov"); + contact.setUrl("http://localhost:8088/api/test/all"); + + Info info = new Info() + .title("Tutorial Hotel Booking API.") + .version("1.0") + .contact(contact) + .description("This API exposes endpoints to manage hotel booking."); + + return new OpenAPI() + .info(info) + .servers(List.of(devServer)); + } +} 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..b19f5458 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/RedisConfig.java @@ -0,0 +1,39 @@ +package com.emobile.springtodo.config; + +import com.emobile.springtodo.config.property.cache.AppCacheProperties; +import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true") +public class RedisConfig { + /** + * Redis cache configuration. + * + * @return configuration with updated cache expires + * @see AppCacheProperties + */ + @Bean + public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer( + AppCacheProperties appCacheProperties) { + Map redisConfiguration = new HashMap<>(); + appCacheProperties.getCacheNames().forEach(cacheName -> + redisConfiguration.put(cacheName, + RedisCacheConfiguration.defaultCacheConfig() + .entryTtl( + appCacheProperties.getProperties() + .get(cacheName) + .getExpiry() + ) + ) + ); + return builder -> builder + .withInitialCacheConfigurations(redisConfiguration); + } +} 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..a2773d1b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/SecurityConfig.java @@ -0,0 +1,117 @@ +package com.emobile.springtodo.config; + + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +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.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Security configuration to implement authorization and authentication. + */ +@Configuration +@EnableMethodSecurity +@EnableWebSecurity +public class SecurityConfig { + /** + * Bean {@link PasswordEncoder} for security configure. + * + * @return default {@link BCryptPasswordEncoder} + * @see #authenticationProvider(UserDetailsService, PasswordEncoder) + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * Bean {@link DaoAuthenticationProvider} + * for {@link AuthenticationManager} configure. + * + * @param userDetailsService {@link UserDetailsService} for + * {@link DaoAuthenticationProvider} configuration. + * @param passwordEncoder {@link PasswordEncoder} for + * {@link DaoAuthenticationProvider} configuration. + * @return {@link DaoAuthenticationProvider} with updated configuration + * @see #authenticationManager(HttpSecurity, DaoAuthenticationProvider) + */ + @Bean + public DaoAuthenticationProvider authenticationProvider( + UserDetailsService userDetailsService, + PasswordEncoder passwordEncoder + ) { + DaoAuthenticationProvider authenticationProvider = + new DaoAuthenticationProvider(); + authenticationProvider.setUserDetailsService(userDetailsService); + authenticationProvider.setPasswordEncoder(passwordEncoder); + return authenticationProvider; + } + + /** + * Bean {@link AuthenticationManager} for authentication configure. + * + * @param http {@link HttpSecurity} to get + * {@link AuthenticationManagerBuilder}. + * @param authenticationProvider {@link DaoAuthenticationProvider} + * for {@link AuthenticationManager} configuration. + * @return {@link AuthenticationManager} with updated configuration. + * @throws Exception if {@link HttpSecurity} throws exception. + * @see #filterChain(HttpSecurity, AuthenticationManager) + */ + @Bean + public AuthenticationManager authenticationManager( + HttpSecurity http, + DaoAuthenticationProvider authenticationProvider + ) throws Exception { + var authenticationBuilder = + http.getSharedObject(AuthenticationManagerBuilder.class); + authenticationBuilder.authenticationProvider(authenticationProvider); + return authenticationBuilder.build(); + } + + /** + * Bean {@link SecurityFilterChain} initiation + * to implement base authorization and authentication.
+ * Adds the Security headers to the response. + * + * @param http {@link HttpSecurity} for authorization settings. + * @param authenticationManager {@link AuthenticationManager} with updated configuration. + * @return {@link SecurityFilterChain} with updated configuration. + * @throws Exception if {@link HttpSecurity} throws exception. + */ + @Bean + public SecurityFilterChain filterChain( + HttpSecurity http, + AuthenticationManager authenticationManager + ) throws Exception { + http.authorizeHttpRequests(auth -> auth + .requestMatchers("/error").permitAll() + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/api/test/**").permitAll() + .requestMatchers("/swagger-ui/**").permitAll() + .requestMatchers("/v3/api-docs/**").permitAll() + .anyRequest() + .authenticated() + ) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(Customizer.withDefaults()) + .sessionManagement(httpSecuritySessionManagementConfigurer -> + httpSecuritySessionManagementConfigurer.sessionCreationPolicy( + SessionCreationPolicy.STATELESS + ) + ) + .authenticationManager(authenticationManager); + return http.build(); + } +} diff --git a/src/main/java/com/emobile/springtodo/config/property/cache/AppCacheProperties.java b/src/main/java/com/emobile/springtodo/config/property/cache/AppCacheProperties.java new file mode 100644 index 00000000..279b4950 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/property/cache/AppCacheProperties.java @@ -0,0 +1,36 @@ +package com.emobile.springtodo.config.property.cache; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Data +@ConfigurationProperties(prefix = "app.cache") +public class AppCacheProperties { + private final List cacheNames = new ArrayList<>(); + private final Map properties = new HashMap<>(); + + @Data + public static class CacheProperties { + private Duration expiry = Duration.ZERO; + } + + /** + * Actually global variables. + * May not match values from config. + */ + public static final class Types { + public static final String USERS = "users"; + public static final String USER_BY_ID = "userById"; + public static final String USER_BY_NAME = "userByName"; + public static final String TASKS = "tasks"; + public static final String TASK_BY_ID = "taskById"; + + private Types() {} + } +} diff --git a/src/main/java/com/emobile/springtodo/controller/AppController.java b/src/main/java/com/emobile/springtodo/controller/AppController.java new file mode 100644 index 00000000..aada94d0 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/AppController.java @@ -0,0 +1,111 @@ +package com.emobile.springtodo.controller; + +import com.emobile.springtodo.aop.LazyLogger; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "TestController", + description = "Test Controller for secured API calls.") +@RequestMapping("api/test") +@RestController +public class AppController { + /** + * Testing endpoint with public access. + * + * @return Public response data text message. + */ + @Operation( + summary = "Testing endpoint with public access.", + tags = {"test", "get", "access", "public"}) + @ApiResponse(responseCode = "200") + @ApiResponse(responseCode = "404") + @ApiResponse(responseCode = "500") + @GetMapping("/all") + @LazyLogger + public String allAccess() { + return "Public response data."; + } + + /** + * Testing endpoint with admin access. + * + * @return Admin response data text message. + */ + @Operation( + summary = "Testing endpoint with admin access.", + tags = {"test", "get", "access"}) + @ApiResponse(responseCode = "200") + @ApiResponse(responseCode = "403") + @ApiResponse(responseCode = "404") + @ApiResponse(responseCode = "500") + @GetMapping("/admin") + @PreAuthorize("hasRole('ADMIN')") + @LazyLogger + public String adminAccess() { + return "Admin response data."; + } + + /** + * Testing endpoint with manager access. + * + * @return Moderator response data text message. + */ + @Operation( + summary = "Testing endpoint with manager access.", + tags = {"test", "get", "access"}) + @ApiResponse(responseCode = "200") + @ApiResponse(responseCode = "403") + @ApiResponse(responseCode = "404") + @ApiResponse(responseCode = "500") + @GetMapping("/manager") + @PreAuthorize("hasRole('MODERATOR')") + @LazyLogger + public String managerAccess() { + return "Moderator response data."; + } + + /** + * Testing endpoint with user access. + * + * @return User response data text message. + */ + @Operation( + summary = "Testing endpoint with user access.", + tags = {"test", "get", "access"}) + @ApiResponse(responseCode = "200") + @ApiResponse(responseCode = "403") + @ApiResponse(responseCode = "404") + @ApiResponse(responseCode = "500") + @GetMapping("/user") + @PreAuthorize("hasRole('USER')") + @LazyLogger + public String userAccess() { + return "User response data."; + } + + /** + * Testing endpoint with any auth access. + * + * @return Authenticated response data text message. + */ + @Operation( + summary = "Testing endpoint with any auth access.", + description = "user or admin access.", + tags = {"test", "get", "access"}) + @ApiResponse(responseCode = "200") + @ApiResponse(responseCode = "403") + @ApiResponse(responseCode = "404") + @ApiResponse(responseCode = "500") + @GetMapping("/any") + @PreAuthorize("isAuthenticated()") + @LazyLogger + public String anyAccess() { + return "Authenticated response data."; + } +} + 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..085dcef4 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/AuthController.java @@ -0,0 +1,66 @@ +package com.emobile.springtodo.controller; + +import com.emobile.springtodo.aop.LazyLogger; +import com.emobile.springtodo.mapper.UserMapper; +import com.emobile.springtodo.model.dto.user.UserSaveRequest; +import com.emobile.springtodo.model.dto.util.SimpleResponse; +import com.emobile.springtodo.service.security.SecurityService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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; + +/** + * Authentication Controller for new user registration. + * + * @see UserSaveRequest + */ +@Tag(name = "AuthenticationController", + description = "User authentication controller.") +@RequiredArgsConstructor +@RequestMapping("api/auth") +@RestController +public class AuthController { + /** + * Service for work with new user. + */ + private final SecurityService securityService; + /** + * Mapper for user entity. + */ + private final UserMapper userMapper; + + /** + * Map {@link UserSaveRequest} to user in {@link UserMapper} and register it. + * + * @param userSaveRequest {@link UserSaveRequest} for registration. + * @return {@link ResponseEntity} with {@link SimpleResponse} if created. + */ + @Operation( + summary = "Register new User.", + tags = {"auth", "post", "register", "public"}) + @ApiResponse(responseCode = "201", content = { + @Content(schema = @Schema(implementation = SimpleResponse.class)) + }) + @ApiResponse(responseCode = "400") + @PostMapping("/register") + @LazyLogger + public ResponseEntity registerUser( + @RequestBody @Valid UserSaveRequest userSaveRequest) { + securityService.registerNewUser( + userMapper.requestToModel(userSaveRequest) + ); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(new SimpleResponse("User created!")); + } +} diff --git a/src/main/java/com/emobile/springtodo/controller/TaskController.java b/src/main/java/com/emobile/springtodo/controller/TaskController.java new file mode 100644 index 00000000..7a16fc42 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/TaskController.java @@ -0,0 +1,208 @@ +package com.emobile.springtodo.controller; + +import com.emobile.springtodo.aop.LazyLogger; +import com.emobile.springtodo.mapper.TaskMapper; +import com.emobile.springtodo.model.dto.task.TaskInsertRequest; +import com.emobile.springtodo.model.dto.task.TaskListResponse; +import com.emobile.springtodo.model.dto.task.TaskResponse; +import com.emobile.springtodo.model.dto.task.TaskSaveRequest; +import com.emobile.springtodo.model.util.PageInfo; +import com.emobile.springtodo.service.TaskService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Controller for working with entity user. + * + * @see TaskSaveRequest + * @see TaskInsertRequest + * @see TaskResponse + * @see TaskListResponse + */ +@Tag(name = "TaskController", + description = "Controller for working with tasks.") +@RequiredArgsConstructor +@Validated +@RestController +@RequestMapping("/api/task") +public class TaskController { + /** + * Service for working with entity task. + */ + private final TaskService taskService; + /** + * Mapper for working with entity task. + */ + private final TaskMapper taskMapper; + + /** + * Get all taskList. + * + * @return {@link ResponseEntity} with {@link HttpStatus#OK} + * and {@link TaskListResponse}. + */ + @Operation( + summary = "Get all tasks.", + description = "Only with admin access.", + tags = {"task", "get"}) + @ApiResponse(responseCode = "200", content = { + @Content(schema = @Schema(implementation = TaskListResponse.class)) + }) + @ApiResponse(responseCode = "401") + @ApiResponse(responseCode = "403") + @PreAuthorize("hasRole('ADMIN')") + @GetMapping() + @LazyLogger + public ResponseEntity getAll(@Valid PageInfo pageInfo) { + return ResponseEntity.status(HttpStatus.OK) + .body(taskMapper.modelListToModelListResponse( + taskService.findAll(pageInfo) + )); + } + + /** + * Get a task object by specifying its {@code id}. + * The response is task object with + * id, username, password, email, content roles. + * + * @param id the {@code id} of the {@code user} to retrieve. + * @return {@link ResponseEntity} with {@link HttpStatus#OK} + * and {@link TaskResponse} with searched {@code id}. + */ + @Operation( + summary = "Get task by id.", + description = "Get a Task object by specifying its id. " + + "The response is Task object with " + + "id, name, description, status, createdAt, updatedAt, authorId.", + tags = {"task", "get"}) + @ApiResponse(responseCode = "200", content = { + @Content(schema = @Schema(implementation = TaskResponse.class)) + }) + @ApiResponse(responseCode = "400") + @ApiResponse(responseCode = "401") + @PreAuthorize("hasAnyRole('USER','ADMIN')") + @GetMapping(path = "/{id}") + @LazyLogger + public ResponseEntity getById(@PathVariable("id") Long id) { + return ResponseEntity.status(HttpStatus.OK) + .body(taskMapper.modelToResponse(taskService.findById(id))); + } + + /** + * Create new task by {@link TaskSaveRequest}. + * + * @param modelRequest {@link TaskSaveRequest} to create new task. + * @return {@link ResponseEntity} with {@link HttpStatus#CREATED} + * and {@link TaskResponse} by saved task. + */ + @Operation( + summary = "Create new task.", + description = "Only with admin access.", + tags = {"task", "post"}) + @ApiResponse(responseCode = "201", content = { + @Content(schema = @Schema(implementation = TaskResponse.class)) + }) + @ApiResponse(responseCode = "400") + @ApiResponse(responseCode = "401") + @PreAuthorize("hasAnyRole('USER','ADMIN')") + @PostMapping() + @LazyLogger + public ResponseEntity create( + @RequestBody @Valid TaskSaveRequest modelRequest) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(taskMapper.modelToResponse( + taskService.save( + taskMapper.requestToModel(modelRequest) + ) + )); + } + + /** + * Update task with {@code id} by {@link TaskInsertRequest}. + * + * @param id task {@code id} to update task. + * @param modelRequest {@link TaskInsertRequest} to update task. + * @return {@link ResponseEntity} with {@link HttpStatus#OK} + * and {@link TaskResponse} by updated task. + */ + @Operation( + summary = "Update task by specifying its id.", + tags = {"task", "put"}) + @ApiResponse(responseCode = "200", content = { + @Content(schema = @Schema(implementation = TaskResponse.class)) + }) + @ApiResponse(responseCode = "400") + @ApiResponse(responseCode = "401") + @PreAuthorize("hasAnyRole('USER','ADMIN')") + @PutMapping(path = "/{id}") + @LazyLogger + public ResponseEntity update( + @PathVariable("id") Long id, + @RequestBody @Valid TaskInsertRequest modelRequest + ) { + return ResponseEntity.status(HttpStatus.OK) + .body(taskMapper.modelToResponse( + taskService.update( + id, + taskMapper.requestToModel(modelRequest) + ) + )); + } + + /** + * Delete task by {@code id}. + * + * @param id task {@code id} to delete task. + * @return {@link ResponseEntity} with {@link HttpStatus#NO_CONTENT}. + */ + @Operation( + summary = "Delete task by specifying its id.", + tags = {"task", "delete"}) + @ApiResponse(responseCode = "204") + @ApiResponse(responseCode = "400") + @ApiResponse(responseCode = "401") + @PreAuthorize("hasAnyRole('USER','ADMIN')") + @DeleteMapping(path = "/{id}") + @LazyLogger + public ResponseEntity deleteById(@PathVariable("id") Long id) { + taskService.deleteById(id); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + /** + * Delete all tasks. + * + * @return {@link ResponseEntity} with {@link HttpStatus#NO_CONTENT}. + */ + @Operation( + summary = "Delete all tasks.", + tags = {"task", "delete"}) + @ApiResponse(responseCode = "204") + @ApiResponse(responseCode = "400") + @ApiResponse(responseCode = "401") + @ApiResponse(responseCode = "403") + @PreAuthorize("hasRole('ADMIN')") + @DeleteMapping(path = "") + @LazyLogger + public ResponseEntity deleteAll() { + taskService.deleteAll(); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} 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..87c29f0a --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/UserController.java @@ -0,0 +1,160 @@ +package com.emobile.springtodo.controller; + +import com.emobile.springtodo.aop.LazyLogger; +import com.emobile.springtodo.mapper.UserMapper; +import com.emobile.springtodo.model.dto.user.UserInsertRequest; +import com.emobile.springtodo.model.dto.user.UserListResponse; +import com.emobile.springtodo.model.dto.user.UserResponse; +import com.emobile.springtodo.model.dto.user.UserResponseWith; +import com.emobile.springtodo.model.dto.user.UserSaveRequest; +import com.emobile.springtodo.model.util.PageInfo; +import com.emobile.springtodo.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Controller for working with entity user. + * + * @see UserSaveRequest + * @see UserInsertRequest + * @see UserResponse + * @see UserResponseWith + * @see UserListResponse + */ +@Tag(name = "UserController", + description = "Controller for working with users.") +@RequiredArgsConstructor +@Validated +@RestController +@RequestMapping("/api/user") +public class UserController { + /** + * Service for working with entity user. + */ + private final UserService userService; + /** + * Mapper for working with entity user. + */ + private final UserMapper userMapper; + + /** + * Get all users. + * + * @return {@link ResponseEntity} with {@link HttpStatus#OK} + * and {@link UserListResponse}. + */ + @Operation( + summary = "Get all users.", + description = "Only with admin access.", + tags = {"user", "get"}) + @ApiResponse(responseCode = "200", content = { + @Content(schema = @Schema(implementation = UserListResponse.class)) + }) + @ApiResponse(responseCode = "401") + @ApiResponse(responseCode = "403") + @PreAuthorize("hasRole('ADMIN')") + @GetMapping() + @LazyLogger + public ResponseEntity getAll(@Valid PageInfo pageInfo) { + return ResponseEntity.status(HttpStatus.OK) + .body(userMapper.modelListToModelListResponse( + userService.findAll(pageInfo) + )); + } + + /** + * Get a user object by specifying its {@code id}. + * The response is user object with + * id, username, password, email, content roles. + * + * @param id the {@code id} of the {@code user} to retrieve. + * @return {@link ResponseEntity} with {@link HttpStatus#OK} + * and {@link UserResponse} with searched {@code id}. + */ + @Operation( + summary = "Get user by id.", + description = "Get a User object by specifying its id. " + + "The response is User object with " + + "id, username, password, email, content roles.", + tags = {"user", "get"}) + @ApiResponse(responseCode = "200", content = { + @Content(schema = @Schema(implementation = UserResponseWith.class)) + }) + @ApiResponse(responseCode = "400") + @ApiResponse(responseCode = "401") + @PreAuthorize("hasAnyRole('USER','ADMIN')") + @GetMapping(path = "/{id}") + @LazyLogger + public ResponseEntity getById(@PathVariable("id") Long id) { + return ResponseEntity.status(HttpStatus.OK) + .body(userMapper.modelToResponseWith(userService.findById(id))); + } + + /** + * Update user with {@code id} by {@link UserSaveRequest}. + * + * @param id user {@code id} to update user. + * @param modelRequest {@link UserSaveRequest} to update user. + * @return {@link ResponseEntity} with {@link HttpStatus#OK} + * and {@link UserResponse} by updated user. + */ + @Operation( + summary = "Update user by specifying its id.", + tags = {"user", "put"}) + @ApiResponse(responseCode = "200", content = { + @Content(schema = @Schema(implementation = UserResponse.class)) + }) + @ApiResponse(responseCode = "400") + @ApiResponse(responseCode = "401") + @PreAuthorize("hasAnyRole('USER','ADMIN')") + @PutMapping(path = "/{id}") + @LazyLogger + public ResponseEntity update( + @PathVariable("id") Long id, + @RequestBody @Valid UserInsertRequest modelRequest + ) { + return ResponseEntity.status(HttpStatus.OK) + .body(userMapper.modelToResponse( + userService.update( + id, + userMapper.requestToModel(modelRequest) + ) + )); + } + + /** + * Delete user by {@code id}. + * + * @param id user {@code id} to delete user. + * @return {@link ResponseEntity} with {@link HttpStatus#NO_CONTENT}. + */ + @Operation( + summary = "Delete user by specifying its id.", + tags = {"user", "delete"}) + @ApiResponse(responseCode = "204") + @ApiResponse(responseCode = "400") + @ApiResponse(responseCode = "401") + @PreAuthorize("hasAnyRole('USER','ADMIN')") + @DeleteMapping(path = "/{id}") + @LazyLogger + public ResponseEntity deleteById(@PathVariable("id") Long id) { + userService.deleteById(id); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} diff --git a/src/main/java/com/emobile/springtodo/controller/handler/ErrorResponse.java b/src/main/java/com/emobile/springtodo/controller/handler/ErrorResponse.java new file mode 100644 index 00000000..2e629526 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/handler/ErrorResponse.java @@ -0,0 +1,9 @@ +package com.emobile.springtodo.controller.handler; + +/** + * Error Response type for ExceptionHandler. + * + * @param errorMessage Error Response message. + */ +public record ErrorResponse(String errorMessage) { +} diff --git a/src/main/java/com/emobile/springtodo/controller/handler/ErrorResponseBody.java b/src/main/java/com/emobile/springtodo/controller/handler/ErrorResponseBody.java new file mode 100644 index 00000000..644e422b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/handler/ErrorResponseBody.java @@ -0,0 +1,15 @@ +package com.emobile.springtodo.controller.handler; + +import lombok.Builder; + +/** + * Error Response type for ExceptionHandler. + * + * @param message Error Response message. + * @param description Error Response description. + */ +@Builder +public record ErrorResponseBody( + String message, + String description) { +} diff --git a/src/main/java/com/emobile/springtodo/controller/handler/ExceptionHandlerController.java b/src/main/java/com/emobile/springtodo/controller/handler/ExceptionHandlerController.java new file mode 100644 index 00000000..01d93a9c --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/handler/ExceptionHandlerController.java @@ -0,0 +1,134 @@ +package com.emobile.springtodo.controller.handler; + +import com.emobile.springtodo.exception.AlreadyExitsException; +import com.emobile.springtodo.exception.DeleteEntityWithReferenceException; +import com.emobile.springtodo.exception.EntityNotFoundException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; + +import java.util.List; + +/** + * Exception Handler Controller for handle exceptions. + * toAdd HttpMessageNotReadableException json parse ex + */ +@RestControllerAdvice +@Slf4j +public class ExceptionHandlerController { + /** + * ExceptionHandler for {@link MethodArgumentNotValidException}. + *
+ * Used for aggregate validation exceptions. + * + * @param ex exception type of {@link MethodArgumentNotValidException}. + * @return {@link ResponseEntity} with {@link ErrorResponse}. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity badRequest( + MethodArgumentNotValidException ex) { + BindingResult bindingResult = ex.getBindingResult(); + List errorMessages = bindingResult.getAllErrors() + .stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .toList(); + String errorMessage = String.join("; ", errorMessages); + log.error("Not valid arguments: " + errorMessage); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(errorMessage)); + } + + /** + * ExceptionHandler for {@link DeleteEntityWithReferenceException}. + * + * @param ex exception type of + * {@link DeleteEntityWithReferenceException}. + * @param webRequest web request for exception description. + * @return {@link ResponseEntity} with {@link ErrorResponseBody}. + * @see #buildResponse(HttpStatus, Exception, WebRequest) + */ + @ExceptionHandler(DeleteEntityWithReferenceException.class) + public ResponseEntity badRequest( + DeleteEntityWithReferenceException ex, + WebRequest webRequest + ) { + return buildResponse(HttpStatus.BAD_REQUEST, ex, webRequest); + } + + /** + * ExceptionHandler for {@link AlreadyExitsException}. + * + * @param ex exception type of {@link AlreadyExitsException}. + * @param webRequest web request for exception description. + * @return {@link ResponseEntity} with {@link ErrorResponseBody}. + * @see #buildResponse(HttpStatus, Exception, WebRequest) + */ + @ExceptionHandler(AlreadyExitsException.class) + public ResponseEntity badRequest( + AlreadyExitsException ex, + WebRequest webRequest + ) { + return buildResponse(HttpStatus.BAD_REQUEST, ex, webRequest); + } + + /** + * ExceptionHandler for {@link EntityNotFoundException}. + * + * @param ex exception type of {@link EntityNotFoundException}. + * @param webRequest web request for exception description. + * @return {@link ResponseEntity} with {@link ErrorResponseBody}. + * @see #buildResponse(HttpStatus, Exception, WebRequest) + */ + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity notFound( + EntityNotFoundException ex, + WebRequest webRequest + ) { + return buildResponse(HttpStatus.NOT_FOUND, ex, webRequest); + } + + /** + * ExceptionHandler for {@link AccessDeniedException}. + * + * @param ex exception type of {@link AccessDeniedException}. + * @param webRequest web request for exception description. + * @return {@link ResponseEntity} with {@link ErrorResponseBody}. + * @see #buildResponse(HttpStatus, Exception, WebRequest) + */ + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity forbidden( + AccessDeniedException ex, + WebRequest webRequest + ) { + return buildResponse(HttpStatus.FORBIDDEN, ex, webRequest); + } + + /** + * Exception response builder. + * + * @param status exception {@link HttpStatus}. + * @param ex exception. + * @param webRequest web request for exception description. + * @return {@link ResponseEntity} with {@link ErrorResponseBody}. + */ + private ResponseEntity buildResponse( + HttpStatus status, + Exception ex, + WebRequest webRequest + ) { + ErrorResponseBody responseBody = ErrorResponseBody.builder() + .message(ex.getMessage()) + .description(webRequest.getDescription(false)) + .build(); + log.error(responseBody.toString(), ex); + return ResponseEntity.status(status) + .body(responseBody); + } +} diff --git a/src/main/java/com/emobile/springtodo/exception/AccessException.java b/src/main/java/com/emobile/springtodo/exception/AccessException.java new file mode 100644 index 00000000..8c25e40b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/AccessException.java @@ -0,0 +1,16 @@ +package com.emobile.springtodo.exception; + +/** + * Exception to handle access problems. + */ +public class AccessException extends RuntimeException { + /** + * Create new Object with message. + * + * @param message exception message. + * @see RuntimeException#RuntimeException(String) + */ + public AccessException(String message) { + super(message); + } +} diff --git a/src/main/java/com/emobile/springtodo/exception/AlreadyExitsException.java b/src/main/java/com/emobile/springtodo/exception/AlreadyExitsException.java new file mode 100644 index 00000000..ebc46cf9 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/AlreadyExitsException.java @@ -0,0 +1,16 @@ +package com.emobile.springtodo.exception; + +/** + * Exception for handle duplicate problem with unique fields. + */ +public class AlreadyExitsException extends RuntimeException { + /** + * Create new Object with message. + * + * @param message exception message. + * @see RuntimeException#RuntimeException(String) + */ + public AlreadyExitsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/emobile/springtodo/exception/DeleteEntityWithReferenceException.java b/src/main/java/com/emobile/springtodo/exception/DeleteEntityWithReferenceException.java new file mode 100644 index 00000000..8ef7a7a1 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/DeleteEntityWithReferenceException.java @@ -0,0 +1,16 @@ +package com.emobile.springtodo.exception; + +/** + * Exception for handle unsupported cascade deleting. + */ +public class DeleteEntityWithReferenceException extends RuntimeException { + /** + * Create new Object with message. + * + * @param message exception message. + * @see RuntimeException#RuntimeException(String) + */ + public DeleteEntityWithReferenceException(String message) { + super(message); + } +} diff --git a/src/main/java/com/emobile/springtodo/exception/EntityNotFoundException.java b/src/main/java/com/emobile/springtodo/exception/EntityNotFoundException.java new file mode 100644 index 00000000..b32424e3 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/EntityNotFoundException.java @@ -0,0 +1,32 @@ +package com.emobile.springtodo.exception; + +import java.util.function.Supplier; + +/** + * Exception to handle entity not found problem. + */ +public class EntityNotFoundException extends RuntimeException { + /** + * Create new Object with message. + * + * @param message exception message. + * @see RuntimeException#RuntimeException(String) + */ + public EntityNotFoundException(String message) { + super(message); + } + + /** + * Create new Object with message. + *
+ * Uses in orElseThrow block. + *
+ * Uses {@link #EntityNotFoundException(String)} + * + * @param message exception message. + * @return Supplier with {@link EntityNotFoundException}. + */ + public static Supplier create(String message) { + return () -> new EntityNotFoundException(message); + } +} diff --git a/src/main/java/com/emobile/springtodo/exception/NullResultSetException.java b/src/main/java/com/emobile/springtodo/exception/NullResultSetException.java new file mode 100644 index 00000000..39bf9a85 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/NullResultSetException.java @@ -0,0 +1,32 @@ +package com.emobile.springtodo.exception; + +import java.util.function.Supplier; + +/** + * Exception for handle unsupported null result in sql query. + */ +public class NullResultSetException extends RuntimeException { + /** + * Create new Object with message. + * + * @param message exception message. + * @see RuntimeException#RuntimeException(String) + */ + public NullResultSetException(String message) { + super(message); + } + + /** + * Create new Object with message. + *
+ * Uses in orElseThrow block. + *
+ * Uses {@link #NullResultSetException(String)} + * + * @param message exception message. + * @return Supplier with {@link NullResultSetException}. + */ + public static Supplier create(String message) { + return () -> new NullResultSetException(message); + } +} diff --git a/src/main/java/com/emobile/springtodo/mapper/TaskMapper.java b/src/main/java/com/emobile/springtodo/mapper/TaskMapper.java new file mode 100644 index 00000000..84977410 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/mapper/TaskMapper.java @@ -0,0 +1,81 @@ +package com.emobile.springtodo.mapper; + +import com.emobile.springtodo.model.dto.task.TaskInsertRequest; +import com.emobile.springtodo.model.dto.task.TaskListResponse; +import com.emobile.springtodo.model.dto.task.TaskResponse; +import com.emobile.springtodo.model.dto.task.TaskSaveRequest; +import com.emobile.springtodo.model.entity.Task; +import org.mapstruct.Mapper; +import org.mapstruct.Named; +import org.mapstruct.ReportingPolicy; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * Mapper for working with {@link Task} entity. + * + * @see TaskSaveRequest + * @see TaskInsertRequest + * @see TaskResponse + * @see TaskListResponse + */ +@Mapper(componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface TaskMapper { + /** + * {@link TaskSaveRequest} to {@link Task} mapping. + * + * @param request {@link TaskSaveRequest} for mapping. + * @return mapped {@link Task}. + */ + Task requestToModel(TaskSaveRequest request); + + /** + * {@link TaskInsertRequest} to {@link Task} mapping. + * + * @param request {@link TaskInsertRequest} for mapping. + * @return mapped {@link Task}. + */ + Task requestToModel(TaskInsertRequest request); + + /** + * {@link Task} to {@link TaskResponse} mapping. + * + * @param model {@link Task} for mapping. + * @return mapped {@link TaskResponse}. + */ +// @Mapping(target = "createdAt", qualifiedByName = "OffsetDateTimeToString") +// @Mapping(target = "updatedAt", qualifiedByName = "OffsetDateTimeToString") + TaskResponse modelToResponse(Task model); + + + @Named("OffsetDateTimeToString") + default String toLocalDateTime(OffsetDateTime value) { + return value.toString(); + } + + /** + * List of {@link Task} to + * content of {@link TaskResponse} mapping. + * + * @param modelList List of {@link Task} for mapping. + * @return mapped List of {@link TaskResponse}. + * @see #modelToResponse(Task) + */ + List modelListToResponseList(List modelList); + + /** + * List of {@link Task} to {@link TaskListResponse} mapping. + * + * @param modelList List of {@link Task} for mapping. + * @return mapped {@link TaskListResponse}. + * @see #modelListToResponseList(List) + */ + default TaskListResponse modelListToModelListResponse( + List modelList) { + return new TaskListResponse( + modelListToResponseList(modelList) + ); + } +} 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..329104e0 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/mapper/UserMapper.java @@ -0,0 +1,82 @@ +package com.emobile.springtodo.mapper; + +import com.emobile.springtodo.model.dto.user.UserInsertRequest; +import com.emobile.springtodo.model.dto.user.UserListResponse; +import com.emobile.springtodo.model.dto.user.UserResponse; +import com.emobile.springtodo.model.dto.user.UserResponseWith; +import com.emobile.springtodo.model.dto.user.UserSaveRequest; +import com.emobile.springtodo.model.entity.User; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +import java.util.List; + +/** + * Mapper for working with {@link User} entity. + * + * @see UserSaveRequest + * @see UserInsertRequest + * @see UserResponse + * @see UserResponseWith + * @see UserListResponse + */ +@Mapper(componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.IGNORE, + uses = TaskMapper.class) +public interface UserMapper { + /** + * {@link UserSaveRequest} to {@link User} mapping. + * + * @param request {@link UserSaveRequest} for mapping. + * @return mapped {@link User}. + */ + User requestToModel(UserSaveRequest request); + + /** + * {@link UserInsertRequest} to {@link User} mapping. + * + * @param request {@link UserInsertRequest} for mapping. + * @return mapped {@link User}. + */ + User requestToModel(UserInsertRequest request); + + /** + * {@link User} to {@link UserResponse} mapping. + * + * @param model {@link User} for mapping. + * @return mapped {@link UserResponse}. + */ + UserResponse modelToResponse(User model); + + /** + * {@link User} to {@link UserResponse} mapping. + * + * @param model {@link User} for mapping. + * @return mapped {@link UserResponse}. + */ + UserResponseWith modelToResponseWith(User model); + + /** + * List of {@link User} to + * content of {@link UserResponse} mapping. + * + * @param modelList List of {@link User} for mapping. + * @return mapped List of {@link UserResponse}. + * @see #modelToResponse(User) + */ + List modelListToResponseList(List modelList); + + /** + * List of {@link User} to {@link UserListResponse} mapping. + * + * @param modelList List of {@link User} for mapping. + * @return mapped {@link UserListResponse}. + * @see #modelListToResponseList(List) + */ + default UserListResponse modelListToModelListResponse( + List modelList) { + return new UserListResponse( + modelListToResponseList(modelList) + ); + } +} diff --git a/src/main/java/com/emobile/springtodo/model/dto/task/TaskInsertRequest.java b/src/main/java/com/emobile/springtodo/model/dto/task/TaskInsertRequest.java new file mode 100644 index 00000000..e23643a7 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/dto/task/TaskInsertRequest.java @@ -0,0 +1,33 @@ +package com.emobile.springtodo.model.dto.task; + +import com.emobile.springtodo.model.entity.TaskStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +/** + * Request DTO for working with entity Task. + */ +@Schema(description = "Task insert entity.") +public record TaskInsertRequest( + @Size(max = MAX_NAME_SIZE, + message = "Field name must be less than" + + MAX_NAME_SIZE) + @Schema(description = "Task name", example = "TaskName") + String name, + @Size(max = MAX_DESCRIPTION_SIZE, + message = "Field description must be less than" + + MAX_DESCRIPTION_SIZE) + @Schema(description = "Task description", example = "TaskDescription") + String description, + @Schema(description = "Task status") + TaskStatus status +) { + /** + * Maximum size of the name field. + */ + private static final int MAX_NAME_SIZE = 20; + /** + * Maximum size of the description field. + */ + private static final int MAX_DESCRIPTION_SIZE = 20; +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/model/dto/task/TaskListResponse.java b/src/main/java/com/emobile/springtodo/model/dto/task/TaskListResponse.java new file mode 100644 index 00000000..93648264 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/dto/task/TaskListResponse.java @@ -0,0 +1,13 @@ +package com.emobile.springtodo.model.dto.task; + +import java.util.List; + +/** + * List Response DTO for working with entity task. + * + * @param taskList content of {@link TaskResponse}. + */ +public record TaskListResponse( + List taskList +) { +} diff --git a/src/main/java/com/emobile/springtodo/model/dto/task/TaskResponse.java b/src/main/java/com/emobile/springtodo/model/dto/task/TaskResponse.java new file mode 100644 index 00000000..168f7718 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/dto/task/TaskResponse.java @@ -0,0 +1,14 @@ +package com.emobile.springtodo.model.dto.task; + +import com.emobile.springtodo.model.entity.TaskStatus; + +public record TaskResponse( + Long id, + String name, + String description, + TaskStatus status, + String createdAt, + String updatedAt, + Long authorId +) { +} diff --git a/src/main/java/com/emobile/springtodo/model/dto/task/TaskSaveRequest.java b/src/main/java/com/emobile/springtodo/model/dto/task/TaskSaveRequest.java new file mode 100644 index 00000000..501b6c3b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/dto/task/TaskSaveRequest.java @@ -0,0 +1,38 @@ +package com.emobile.springtodo.model.dto.task; + +import com.emobile.springtodo.model.entity.TaskStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * Request DTO for working with entity Task. + */ +@Schema(description = "Task save entity.") +public record TaskSaveRequest( + @NotBlank(message = "Field name must be filled!") + @Size(max = MAX_NAME_SIZE, + message = "Field name must be less than" + + MAX_NAME_SIZE) + @Schema(description = "Task name", example = "TaskName") + String name, + @NotBlank(message = "Field description must be filled!") + @Size(max = MAX_DESCRIPTION_SIZE, + message = "Field description must be less than" + + MAX_DESCRIPTION_SIZE) + @Schema(description = "Task description", example = "TaskDescription") + String description, + @NotNull(message = "Field status must be filled!") + @Schema(description = "Task status") + TaskStatus status +) { + /** + * Maximum size of the name field. + */ + private static final int MAX_NAME_SIZE = 20; + /** + * Maximum size of the description field. + */ + private static final int MAX_DESCRIPTION_SIZE = 20; +} diff --git a/src/main/java/com/emobile/springtodo/model/dto/user/UserInsertRequest.java b/src/main/java/com/emobile/springtodo/model/dto/user/UserInsertRequest.java new file mode 100644 index 00000000..0eec0b42 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/dto/user/UserInsertRequest.java @@ -0,0 +1,31 @@ +package com.emobile.springtodo.model.dto.user; + +import com.emobile.springtodo.model.security.RoleType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; + +/** + * Request DTO for working with entity user. + */ +@Schema(description = "User insert entity.") +public record UserInsertRequest( + @Size(max = MAX_USERNAME_SIZE, + message = "Field username must be less than" + + MAX_USERNAME_SIZE) + @Schema(description = "User username", example = "user") + String username, + @Size(max = MAX_PASSWORD_SIZE, + message = "Field password must be less than" + + MAX_PASSWORD_SIZE) + @Schema(description = "User password", example = "pass") + String password, + @Email(message = "Field email must email format!") + @Schema(description = "User email", example = "user@email.com") + String email, + @Schema(description = "User security role") + RoleType role +) { + private static final int MAX_USERNAME_SIZE = 20; + private static final int MAX_PASSWORD_SIZE = 20; +} diff --git a/src/main/java/com/emobile/springtodo/model/dto/user/UserListResponse.java b/src/main/java/com/emobile/springtodo/model/dto/user/UserListResponse.java new file mode 100644 index 00000000..bb8a0b08 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/dto/user/UserListResponse.java @@ -0,0 +1,13 @@ +package com.emobile.springtodo.model.dto.user; + +import java.util.List; + +/** + * List Response DTO for working with entity user. + * + * @param users content of {@link UserResponse}. + */ +public record UserListResponse( + List users +) { +} diff --git a/src/main/java/com/emobile/springtodo/model/dto/user/UserResponse.java b/src/main/java/com/emobile/springtodo/model/dto/user/UserResponse.java new file mode 100644 index 00000000..99f17e89 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/dto/user/UserResponse.java @@ -0,0 +1,21 @@ +package com.emobile.springtodo.model.dto.user; + +import com.emobile.springtodo.model.security.RoleType; + +/** + * Response DTO for working with entity user. + * + * @param id user id. + * @param username user username. + * @param password user password. + * @param email user email. + * @param role authentication {@link RoleType}. + */ +public record UserResponse( + Long id, + String username, + String password, + String email, + RoleType role +) { +} diff --git a/src/main/java/com/emobile/springtodo/model/dto/user/UserResponseWith.java b/src/main/java/com/emobile/springtodo/model/dto/user/UserResponseWith.java new file mode 100644 index 00000000..8a0c9a63 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/dto/user/UserResponseWith.java @@ -0,0 +1,16 @@ +package com.emobile.springtodo.model.dto.user; + +import com.emobile.springtodo.model.dto.task.TaskResponse; +import com.emobile.springtodo.model.security.RoleType; + +import java.util.List; + +public record UserResponseWith( + Long id, + String username, + String password, + String email, + RoleType role, + List taskList +) { +} diff --git a/src/main/java/com/emobile/springtodo/model/dto/user/UserSaveRequest.java b/src/main/java/com/emobile/springtodo/model/dto/user/UserSaveRequest.java new file mode 100644 index 00000000..6fe8f5bf --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/dto/user/UserSaveRequest.java @@ -0,0 +1,37 @@ +package com.emobile.springtodo.model.dto.user; + +import com.emobile.springtodo.model.security.RoleType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * Request DTO for working with entity user. + */ +@Schema(description = "User save entity.") +public record UserSaveRequest( + @NotBlank(message = "Field username must be filled!") + @Size(max = MAX_USERNAME_SIZE, + message = "Field username must be less than" + + MAX_USERNAME_SIZE) + @Schema(description = "User username", example = "user") + String username, + @NotBlank(message = "Field password must be filled!") + @Size(max = MAX_PASSWORD_SIZE, + message = "Field password must be less than" + + MAX_PASSWORD_SIZE) + @Schema(description = "User password", example = "pass") + String password, + @NotNull(message = "Field email must be filled!") + @Email(message = "Field email must email format!") + @Schema(description = "User email", example = "user@email.com") + String email, + @NotNull(message = "Field role must be filled!") + @Schema(description = "User security role") + RoleType role +) { + private static final int MAX_USERNAME_SIZE = 20; + private static final int MAX_PASSWORD_SIZE = 20; +} diff --git a/src/main/java/com/emobile/springtodo/model/dto/util/SimpleRequest.java b/src/main/java/com/emobile/springtodo/model/dto/util/SimpleRequest.java new file mode 100644 index 00000000..baceb5d9 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/dto/util/SimpleRequest.java @@ -0,0 +1,14 @@ +package com.emobile.springtodo.model.dto.util; + +import jakarta.validation.constraints.NotBlank; + +/** + * Simple Request message DTO. + * + * @param message String message. + */ +public record SimpleRequest( + @NotBlank(message = "Field message must be filled!") + String message +) { +} diff --git a/src/main/java/com/emobile/springtodo/model/dto/util/SimpleResponse.java b/src/main/java/com/emobile/springtodo/model/dto/util/SimpleResponse.java new file mode 100644 index 00000000..ce0b7e1b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/dto/util/SimpleResponse.java @@ -0,0 +1,11 @@ +package com.emobile.springtodo.model.dto.util; + +/** + * Simple Response message DTO. + * + * @param message String message. + */ +public record SimpleResponse( + String message +) { +} diff --git a/src/main/java/com/emobile/springtodo/model/entity/Task.java b/src/main/java/com/emobile/springtodo/model/entity/Task.java new file mode 100644 index 00000000..88ad60b8 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/entity/Task.java @@ -0,0 +1,56 @@ +package com.emobile.springtodo.model.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.FieldNameConstants; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * Entity Task. + */ +@AllArgsConstructor +@NoArgsConstructor +@EqualsAndHashCode +@ToString +@Getter +@FieldNameConstants +@Builder +public class Task implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * Long Task id. + */ + private Long id; + /** + * Task name. + */ + private String name; + /** + * Task description. + */ + private String description; + /** + * Task {@link TaskStatus} status. + */ + private TaskStatus status; + /** + * Task creation time without timezone. + */ + private LocalDateTime createdAt; + /** + * Task update time without timezone. + */ + private LocalDateTime updatedAt; + /** + * The User id who owns the Task. + */ + private Long authorId; +} diff --git a/src/main/java/com/emobile/springtodo/model/entity/TaskStatus.java b/src/main/java/com/emobile/springtodo/model/entity/TaskStatus.java new file mode 100644 index 00000000..4d3c861b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/entity/TaskStatus.java @@ -0,0 +1,7 @@ +package com.emobile.springtodo.model.entity; + +public enum TaskStatus implements java.io.Serializable { + TODO, + IN_PROGRESS, + DONE +} diff --git a/src/main/java/com/emobile/springtodo/model/entity/User.java b/src/main/java/com/emobile/springtodo/model/entity/User.java new file mode 100644 index 00000000..9ab52043 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/entity/User.java @@ -0,0 +1,55 @@ +package com.emobile.springtodo.model.entity; + +import com.emobile.springtodo.model.security.RoleType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.FieldNameConstants; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * Entity User. + */ +@AllArgsConstructor +@NoArgsConstructor +@EqualsAndHashCode +@ToString +@Getter +@FieldNameConstants +@Builder +public class User implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * Long User id. + */ + private Long id; + /** + * User name. + */ + private String username; + /** + * User password. + */ + private String password; + /** + * User email. + */ + private String email; + /** + * User authorization role type. + */ + private RoleType role; + /** + * User tasks. + */ + @Builder.Default + private List taskList = new ArrayList<>(); +} diff --git a/src/main/java/com/emobile/springtodo/model/security/AppUserDetails.java b/src/main/java/com/emobile/springtodo/model/security/AppUserDetails.java new file mode 100644 index 00000000..5f749704 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/security/AppUserDetails.java @@ -0,0 +1,77 @@ +package com.emobile.springtodo.model.security; + +import com.emobile.springtodo.model.entity.User; +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; + +/** + * {@link UserDetails} realization with {@link User} entity. + * {@link #isAccountNonExpired()} always true. + * {@link #isAccountNonLocked()} always true. + * {@link #isCredentialsNonExpired()} always true. + * {@link #isEnabled()} always true. + */ +@RequiredArgsConstructor +@EqualsAndHashCode +@ToString +@Slf4j +public class AppUserDetails implements UserDetails { + /** + * Entity to implement UserDetails. + */ + private final User user; + + @Override + public Collection getAuthorities() { + return Collections.singleton( + new SimpleGrantedAuthority(user.getRole().name()) + ); + } + + /** + * User.id getter. + * + * @return User.id + */ + public Long getUserId() { + return user.getId(); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getUsername(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/emobile/springtodo/model/security/RoleType.java b/src/main/java/com/emobile/springtodo/model/security/RoleType.java new file mode 100644 index 00000000..1a2c7ece --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/security/RoleType.java @@ -0,0 +1,15 @@ +package com.emobile.springtodo.model.security; + +/** + * Authorization role type. + */ +public enum RoleType implements java.io.Serializable { + /** + * Full access role. + */ + ROLE_ADMIN, + /** + * Limited access role. + */ + ROLE_USER +} diff --git a/src/main/java/com/emobile/springtodo/model/util/Page.java b/src/main/java/com/emobile/springtodo/model/util/Page.java new file mode 100644 index 00000000..6a0b799b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/util/Page.java @@ -0,0 +1,4 @@ +package com.emobile.springtodo.model.util; + +public record Page(java.util.List content) { +} diff --git a/src/main/java/com/emobile/springtodo/model/util/PageInfo.java b/src/main/java/com/emobile/springtodo/model/util/PageInfo.java new file mode 100644 index 00000000..46f65ff0 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/util/PageInfo.java @@ -0,0 +1,37 @@ +package com.emobile.springtodo.model.util; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +/** + * Required to parameterize findAll queries. + */ +public record PageInfo( + @NotNull(message = "Field pageSize must be filled!") + @Min(value = MIN_PAGE_SIZE, + message = "Field pageSize must not be less then " + + MIN_PAGE_SIZE) + @Max(value = MAX_PAGE_SIZE, + message = "Field pageSize must not be larger then " + + MAX_PAGE_SIZE) + Integer pageSize, + @Min(value = MIN_NUMBER_SIZE, + message = "Field pageNumber must not be less then " + + MIN_NUMBER_SIZE) + @NotNull(message = "Field pageNumber must be filled!") + Integer pageNumber +) { + /** + * Minimum size of the page field. + */ + private static final int MIN_PAGE_SIZE = 1; + /** + * Maximum size of the page field. + */ + private static final int MAX_PAGE_SIZE = 20; + /** + * Minimum size of the number field. + */ + private static final int MIN_NUMBER_SIZE = 1; +} diff --git a/src/main/java/com/emobile/springtodo/repository/CrudRepository.java b/src/main/java/com/emobile/springtodo/repository/CrudRepository.java new file mode 100644 index 00000000..79cb3d45 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/CrudRepository.java @@ -0,0 +1,55 @@ +package com.emobile.springtodo.repository; + +import com.emobile.springtodo.model.util.Page; +import com.emobile.springtodo.model.util.PageInfo; + +import java.util.Optional; + +/** + * Default CRUD interface repository for working with entity {@link T}. + * + * @param entity for work + */ +public interface CrudRepository { + /** + * Find all {@link T} objects from db + * with {@code pageNumber} and {@code pageSize} from {@code pageInfo}. + * + * @return {@link Page} with {@link T} list. + */ + Page findAll(PageInfo pageinfo); + + /** + * Search object {@link T} in db. + * + * @param id id searched {@link T} object + * @return {@link Optional} if exist, {@link Optional#empty()} if not + */ + Optional findById(Long id); + + /** + * Save object model of type {@link T}. + * + * @param model object of type {@link T} to save + * @return object of type {@link T} that was saved + */ + T save(T model); + + /** + * Update object model of type {@link T} + * by {@code T.id} value. + * + * @param model object of type {@link T} to update + * @return object of type {@link T} that was updated + */ + T update(T model); + + /** + * Delete object with {@code T.id} + * equals {@code id} from database. + * + * @param id id of the object to be deleted + * @return {@code true} if success + */ + boolean deleteById(Long id); +} diff --git a/src/main/java/com/emobile/springtodo/repository/TaskRepository.java b/src/main/java/com/emobile/springtodo/repository/TaskRepository.java new file mode 100644 index 00000000..a1b9b6ee --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/TaskRepository.java @@ -0,0 +1,23 @@ +package com.emobile.springtodo.repository; + +import com.emobile.springtodo.model.entity.Task; + +/** + * Default interface repository for working with entity {@link Task}. + */ +public interface TaskRepository extends CrudRepository { + /** + * Delete all {@link Task} objects in database. + * + * @return {@code true} if success + */ + boolean deleteAll(); + + /** + * Delete all {@link Task} objects in database + * for user with {@code userId}. + * + * @return {@code true} if success + */ + boolean deleteAllByUserId(Long authorId); +} 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..a6693088 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/UserRepository.java @@ -0,0 +1,55 @@ +package com.emobile.springtodo.repository; + +import com.emobile.springtodo.model.entity.User; + +import java.util.Optional; + +/** + * Default interface repository for working with entity {@link User}. + */ +public interface UserRepository extends CrudRepository { + /** + * Find {@link User} + * with {@code User.username} equals {@code username}. + * + * @param username username searched {@link User} + * @return {@link Optional} if exist, {@link Optional#empty()} if not + */ + Optional findByUsername(String username); + + /** + * Check duplicate {@code username}. + * For save {@link User}. + * + * @param username username searched {@link User} + * @return {@code true} if exist, {@code false} if not + */ + boolean existsByUsername(String username); + + /** + * Check duplicate {@code username}. + * For update {@link User}. + * + * @param username username searched {@link User} + * @return {@code true} if exist, {@code false} if not + */ + boolean existsByUsernameAndIdNot(String username, Long currentUserId); + + /** + * Check duplicate {@code email}. + * For save {@link User}. + * + * @param email username searched {@link User} + * @return {@code true} if exist, {@code false} if not + */ + boolean existsByEmail(String email); + + /** + * Check duplicate {@code email}. + * For update {@link User}. + * + * @param email username searched {@link User} + * @return {@code true} if exist, {@code false} if not + */ + boolean existsByEmailAndIdNot(String email, Long currentUserId); +} diff --git a/src/main/java/com/emobile/springtodo/repository/impl/TaskRepositoryImpl.java b/src/main/java/com/emobile/springtodo/repository/impl/TaskRepositoryImpl.java new file mode 100644 index 00000000..d4821f6e --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/impl/TaskRepositoryImpl.java @@ -0,0 +1,205 @@ +package com.emobile.springtodo.repository.impl; + +import com.emobile.springtodo.aop.LazyLogger; +import com.emobile.springtodo.exception.NullResultSetException; +import com.emobile.springtodo.model.entity.Task; +import com.emobile.springtodo.model.entity.TaskStatus; +import com.emobile.springtodo.model.util.Page; +import com.emobile.springtodo.model.util.PageInfo; +import com.emobile.springtodo.repository.TaskRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +import static com.emobile.springtodo.util.JdbcUtil.optionalExtractor; + +/** + * Repository for working with entity {@link Task}. + */ +@RequiredArgsConstructor +@Repository +public class TaskRepositoryImpl implements TaskRepository { + /** + * For work with db. + */ + private final JdbcTemplate jdbcTemplate; + + /** + * Find all {@link Task} objects from db + * with {@code pageNumber} and {@code pageSize} from {@code pageInfo}.
+ * LIMIT - {@code pageSize}.
+ * OFFSET - ({@code pageNumber} -1) * {@code pageSize}. + * + * @return {@link Page} with {@link Task} list. + * @see #getMapper() + */ + @Override + @LazyLogger + public Page findAll(PageInfo pageInfo) { + final String sql = """ + SELECT * FROM tasks + LIMIT ? OFFSET ? + """; + int beginInd = (pageInfo.pageNumber() - 1) * pageInfo.pageSize(); + return new Page<>( + jdbcTemplate.query(sql, + getMapper(), + pageInfo.pageSize(), + beginInd + ) + ); + } + + /** + * Search object {@link Task} in db. + * + * @param id id searched {@link Task} object + * @return {@link Optional} if exist, {@link Optional#empty()} if not + * @see #getMapper() + */ + @Override + @LazyLogger + public Optional findById(Long id) { + final String sql = """ + SELECT * + FROM tasks + WHERE id=? + """; + return jdbcTemplate.query( + sql, + optionalExtractor(getMapper()), + id + ); + } + + /** + * Save object model of type {@link Task}. + * + * @param model object of type {@link Task} to save + * @return object of type {@link Task} that was saved + * @throws NullResultSetException if returning value from db is null + */ + @Override + @LazyLogger + public Task save(Task model) { + final String sql = """ + INSERT INTO tasks(name, description, status, + created_at, updated_at, author_id) + VALUES (?,?,?,?,?,?) + RETURNING * + """; + return jdbcTemplate.query(sql, + optionalExtractor(getMapper()), + model.getName(), + model.getDescription(), + model.getStatus().name(), + model.getCreatedAt(), + model.getUpdatedAt(), + model.getAuthorId() + ).orElseThrow(NullResultSetException.create( + "taskRepository save() query exception" + )); + } + + /** + * Update object model of type {@link Task} + * by {@code T.id} value. + * + * @param model object of type {@link Task} to update + * @return object of type {@link Task} that was updated + * @throws NullResultSetException if returning value from db is null + */ + @Override + @LazyLogger + public Task update(Task model) { + final String sql = """ + UPDATE tasks + SET name=?, description=?, status=?, updated_at=?, author_id =? + WHERE id=? + RETURNING * + """; + return jdbcTemplate.query(sql, + optionalExtractor(getMapper()), + model.getName(), + model.getDescription(), + model.getStatus().name(), + model.getUpdatedAt(), + model.getAuthorId(), + model.getId() + ).orElseThrow(NullResultSetException.create( + "taskRepository update() query exception" + )); + } + + /** + * Delete object with {@code Task.id} + * equals {@code id} from database. + * + * @param id id of the object to be deleted + * @return {@code true} if success + */ + @Override + @LazyLogger + public boolean deleteById(Long id) { + final String sql = """ + DELETE + FROM tasks + WHERE id=? + """; + int result = jdbcTemplate.update(sql, id); + return result != 0; + } + + /** + * Delete all {@link Task} objects in database. + * + * @return {@code true} if success + */ + @Override + @LazyLogger + public boolean deleteAll() { + final String sql = "TRUNCATE tasks"; + int result = jdbcTemplate.update(sql); + return result != 0; + } + + /** + * Delete all {@link Task} objects in database + * for user with {@code userId}. + * + * @return {@code true} if success + */ + @Override + @LazyLogger + public boolean deleteAllByUserId(Long userId) { + final String sql = """ + DELETE + FROM tasks + WHERE author_id=? + """; + int result = jdbcTemplate.update(sql, userId); + return result != 0; + } + + /** + * {@link Task} {@link RowMapper}. + * + * @return mapper for {@link Task} entity + */ + private RowMapper getMapper() { + return (rs, rn) -> Task.builder() + .id(rs.getLong(Task.Fields.id)) + .name(rs.getString(Task.Fields.name)) + .description(rs.getString(Task.Fields.description)) + .status(TaskStatus.valueOf(rs.getString(Task.Fields.status))) + .createdAt(rs.getTimestamp("created_at") + .toLocalDateTime()) + .updatedAt(rs.getTimestamp("updated_at") + .toLocalDateTime()) + .authorId(rs.getLong("author_id")) + .build(); + } +} 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..1b8aad8d --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/impl/UserRepositoryImpl.java @@ -0,0 +1,366 @@ +package com.emobile.springtodo.repository.impl; + +import com.emobile.springtodo.aop.LazyLogger; +import com.emobile.springtodo.exception.NullResultSetException; +import com.emobile.springtodo.model.entity.Task; +import com.emobile.springtodo.model.entity.TaskStatus; +import com.emobile.springtodo.model.entity.User; +import com.emobile.springtodo.model.security.RoleType; +import com.emobile.springtodo.model.util.Page; +import com.emobile.springtodo.model.util.PageInfo; +import com.emobile.springtodo.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static com.emobile.springtodo.util.JdbcUtil.optionalExtractor; + +/** + * Repository for working with entity {@link User}. + */ +@RequiredArgsConstructor +@Repository +public class UserRepositoryImpl implements UserRepository { + /** + * For work with db. + */ + private final JdbcTemplate jdbcTemplate; + + /** + * Find all {@link User} objects from db + * with {@code pageNumber} and {@code pageSize} from {@code pageInfo}.
+ * LIMIT - {@code pageSize}.
+ * OFFSET - ({@code pageNumber} -1) * {@code pageSize}. + * + * @return {@link Page} with {@link User} list. + * @see #getUserMapper() + */ + @Override + @LazyLogger + public Page findAll(PageInfo pageInfo) { + final String sql = """ + SELECT * FROM users + LIMIT ? OFFSET ? + """; + int beginInd = (pageInfo.pageNumber() - 1) * pageInfo.pageSize(); + return new Page<>( + jdbcTemplate.query( + sql, + getUserMapper(), + pageInfo.pageSize(), + beginInd + ) + ); + } + + /** + * Search object {@link User} in db. + * + * @param id id searched {@link User} object + * @return {@link Optional} if exist, {@link Optional#empty()} if not + * @see #getUserTaskMapper() + */ + @Override + @LazyLogger + public Optional findById(Long id) { + final String sql = """ + SELECT u.*, t.* + FROM users u + LEFT JOIN tasks t on u.id = t.author_id + WHERE u.id= ? + """; + return jdbcTemplate.query( + sql, + optionalExtractor(getUserTaskMapper()), + id + ); + } + + /** + * Find {@link User} + * with {@code User.username} equals {@code username}. + * + * @param username username searched {@link User} + * @return {@link Optional} if exist, {@link Optional#empty()} if not + */ + @Override + @LazyLogger + public Optional findByUsername(String username) { + final String sql = """ + SELECT u.*, t.* + FROM users u + LEFT JOIN tasks t on u.id = t.author_id + WHERE u.username= ? + """; + return jdbcTemplate.query( + sql, + optionalExtractor(getUserTaskMapper()), + username + ); + } + + /** + * Save object model of type {@link User}. + * + * @param model object of type {@link User} to save + * @return object of type {@link User} that was saved + * @throws NullResultSetException if returning value from db is null + */ + @Override + @LazyLogger + public User save(User model) { + final String sql = """ + INSERT INTO users(username, password, email, role) + VALUES (?,?,?,?) + RETURNING * + """; + return jdbcTemplate.query(sql, + optionalExtractor(getUserMapper()), + model.getUsername(), + model.getPassword(), + model.getEmail(), + model.getRole().name()) + .orElseThrow(NullResultSetException.create( + "userRepository save() query exception" + )); + } + + /** + * Update object model of type {@link User} + * by {@code T.id} value. + * + * @param model object of type {@link User} to update + * @return object of type {@link User} that was updated + * @throws NullResultSetException if returning value from db is null + */ + @Override + @LazyLogger + public User update(User model) { + final String sql = """ + UPDATE users + SET username=?, password=?, email=?, role=? + WHERE id=? + RETURNING * + """; + return jdbcTemplate.query(sql, + optionalExtractor(getUserMapper()), + model.getUsername(), + model.getPassword(), + model.getEmail(), + model.getRole().name(), + model.getId()) + .orElseThrow(NullResultSetException.create( + "userRepository update() query exception" + )); + } + + /** + * Delete object with {@code User.id} + * equals {@code id} from database. + * + * @param id id of the object to be deleted + * @return {@code true} if success + */ + @Override + @LazyLogger + public boolean deleteById(Long id) { + final String sql = """ + DELETE + FROM users + WHERE id=? + """; + int result = jdbcTemplate.update(sql, id); + return result != 0; + } + + /** + * Check duplicate {@code username}. + * For save {@link User}. + * + * @param username username searched {@link User} + * @return {@code true} if exist, {@code false} if not + * @see #getExistsMapper() + */ + @Override + @LazyLogger + public boolean existsByUsername(String username) { + final String sql = """ + SELECT EXISTS (SELECT * + FROM users + WHERE username = ?) + AS result + """; + return jdbcTemplate.queryForObject(sql, getExistsMapper(), username); + } + + /** + * Check duplicate {@code username}. + * For update {@link User}. + * + * @param username username searched {@link User} + * @return {@code true} if exist, {@code false} if not + * @see #getExistsMapper() + */ + @Override + @LazyLogger + public boolean existsByUsernameAndIdNot(String username, Long currentUserId) { + final String sql = """ + SELECT EXISTS (SELECT * + FROM users + WHERE username = ? + AND id != ?) + AS result + """; + return jdbcTemplate.queryForObject( + sql, + getExistsMapper(), + username, + currentUserId + ); + } + + /** + * Check duplicate {@code email}. + * For save {@link User}. + * + * @param email username searched {@link User} + * @return {@code true} if exist, {@code false} if not + * @see #getExistsMapper() + */ + @Override + @LazyLogger + public boolean existsByEmail(String email) { + final String sql = """ + SELECT EXISTS (SELECT * + FROM users + WHERE email = ?) + AS result + """; + return jdbcTemplate.queryForObject(sql, getExistsMapper(), email); + } + + /** + * Check duplicate {@code email}. + * For update {@link User}. + * + * @param email username searched {@link User} + * @return {@code true} if exist, {@code false} if not + * @see #getExistsMapper() + */ + @Override + @LazyLogger + public boolean existsByEmailAndIdNot(String email, Long currentUserId) { + final String sql = """ + SELECT EXISTS (SELECT * + FROM users + WHERE email = ? + AND id != ?) + AS result + """; + return jdbcTemplate.queryForObject( + sql, + getExistsMapper(), + email, + currentUserId + ); + } + + /** + * {@link User} {@link RowMapper}. + * + * @return mapper for {@link User} entity + */ + private RowMapper getUserMapper() { + return (rs, rn) -> User.builder() + .id(rs.getLong(User.Fields.id)) + .username(rs.getString(User.Fields.username)) + .password(rs.getString(User.Fields.password)) + .email(rs.getString(User.Fields.email)) + .role(RoleType.valueOf(rs.getString(User.Fields.role))) + .build(); + } + + /** + * Грустно и костыльно, но работает. + *
+ * {code rs.getLong("author_id")} for wasNull() check. + * + * @return mapper for {@link User} with filled {@code taskList} fields + * @see #fillUserFields(ResultSet, List) + * @see #fillTaskFields(ResultSet) + */ + private RowMapper getUserTaskMapper() { + return (rs, rn) -> { + List taskList = new ArrayList<>(); + User user = fillUserFields(rs, taskList); + rs.getLong("author_id"); + if (!rs.wasNull()) { + do { + Task task = fillTaskFields(rs); + taskList.add(task); + } while (rs.next()); + } + return user; + }; + } + + /** + * fill {@link User} entity. + * {@code id} columnIndex - 1. + * + * @param rs resultSet for map + * @param taskList list ref + * @return {@link User} with filled fields + * @throws SQLException if resultSet get exception + */ + private User fillUserFields(ResultSet rs, List taskList) throws SQLException { + return User.builder() + .id(rs.getLong(1)) + .username(rs.getString(User.Fields.username)) + .password(rs.getString(User.Fields.password)) + .email(rs.getString(User.Fields.email)) + .role(RoleType.valueOf(rs.getString(User.Fields.role))) + .taskList(taskList) + .build(); + } + + /** + * fill {@link Task} entity. + * {@code id} columnIndex - 6. + * + * @param rs resultSet for map + * @return {@link Task} with filled fields + * @throws SQLException if resultSet get exception + */ + private Task fillTaskFields(ResultSet rs) throws SQLException { + return Task.builder() + .id(rs.getLong(6)) + .name(rs.getString(Task.Fields.name)) + .description(rs.getString(Task.Fields.description)) + .status(TaskStatus.valueOf(rs.getString(Task.Fields.status))) + .createdAt(rs.getTimestamp("created_at") + .toLocalDateTime()) + .updatedAt(rs.getTimestamp("updated_at") + .toLocalDateTime()) + .authorId(rs.getLong("author_id")) + .build(); + } + + /** + * {@link RowMapper} for exist check. + * + * @return mapper for exist check + */ + private RowMapper getExistsMapper() { + return (resultSet, rowNum) -> + resultSet.getString("result") + .equals("t"); + } +} diff --git a/src/main/java/com/emobile/springtodo/service/CrudService.java b/src/main/java/com/emobile/springtodo/service/CrudService.java new file mode 100644 index 00000000..b25d8bb6 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/CrudService.java @@ -0,0 +1,56 @@ +package com.emobile.springtodo.service; + +import com.emobile.springtodo.model.util.Page; +import com.emobile.springtodo.model.util.PageInfo; + +import java.util.List; + +/** + * Default CRUD interface service for working with entity {@link T}. + * + * @param entity for work + */ +public interface CrudService { + /** + * Find all {@link T} objects from db + * with {@code pageNumber} and {@code pageSize} from {@code pageInfo}. + * + * @return {@link Page} with {@link T} list. + */ + List findAll(PageInfo pageInfo); + + /** + * Find object of type {@link T} + * where {@code T.id} equals {@code id}. + * + * @param id id searched {@link T} object + * @return object of type {@link T} with searched {@code id} + */ + T findById(Long id); + + /** + * Save object model of type {@link T}. + * + * @param model object of type {@link T} to save + * @return object of type {@link T} that was saved + */ + T save(T model); + + /** + * Update object model of type {@link T} + * with {@code T.id} equals {@code id}. + * + * @param id id of the object to be updated + * @param model object of type {@link T} to update + * @return object of type {@link T} that was updated + */ + T update(Long id, T model); + + /** + * Delete object with {@code T.id} + * equals {@code id} from database. + * + * @param id id of the object to be deleted + */ + void deleteById(Long id); +} diff --git a/src/main/java/com/emobile/springtodo/service/TaskService.java b/src/main/java/com/emobile/springtodo/service/TaskService.java new file mode 100644 index 00000000..4db7c7c0 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/TaskService.java @@ -0,0 +1,19 @@ +package com.emobile.springtodo.service; + +import com.emobile.springtodo.model.entity.Task; + +/** + * Default interface service for working with entity {@link Task}. + */ +public interface TaskService extends CrudService { + /** + * Delete all {@link Task} objects. + */ + void deleteAll(); + + /** + * Delete all {@link Task} objects + * for user with {@code userId}. + */ + void deleteAllByUserId(Long id); +} 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..09f6fd1b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/UserService.java @@ -0,0 +1,50 @@ +package com.emobile.springtodo.service; + +import com.emobile.springtodo.model.entity.User; + +/** + * Default interface service for working with entity {@link User}. + */ +public interface UserService extends CrudService { + /** + * Find {@link User} + * with {@code User.username} equals {@code username}. + * + * @param username username searched {@link User}. + * @return {@link User} with searched username. + */ + User findByUsername(String username); + + /** + * Check duplicate {@code username}. + * For save {@link User}. + * + * @param username username to check + */ + void checkDuplicateUsername(String username); + + /** + * Check duplicate {@code username}. + * For update {@link User}. + * + * @param username username to check + * @param currentUserId current user id + */ + void checkDuplicateUsername(String username, Long currentUserId); + + /** + * Check duplicate {@code email}. + * For save {@link User}. + * + * @param email username searched {@link User} + */ + void checkDuplicateEmail(String email); + + /** + * Check duplicate {@code email}. + * For update {@link User}. + * + * @param email username searched {@link User} + */ + void checkDuplicateEmail(String email, Long currentUserId); +} diff --git a/src/main/java/com/emobile/springtodo/service/impl/TaskServiceImpl.java b/src/main/java/com/emobile/springtodo/service/impl/TaskServiceImpl.java new file mode 100644 index 00000000..71594d79 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/impl/TaskServiceImpl.java @@ -0,0 +1,241 @@ +package com.emobile.springtodo.service.impl; + +import com.emobile.springtodo.aop.LazyLogger; +import com.emobile.springtodo.config.property.cache.AppCacheProperties; +import com.emobile.springtodo.exception.EntityNotFoundException; +import com.emobile.springtodo.model.entity.Task; +import com.emobile.springtodo.model.security.AppUserDetails; +import com.emobile.springtodo.model.util.PageInfo; +import com.emobile.springtodo.repository.TaskRepository; +import com.emobile.springtodo.service.TaskService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.context.annotation.Lazy; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.MessageFormat; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; + +/** + * Service for working with entity {@link Task}. + */ +@RequiredArgsConstructor +@CacheConfig +@Service +public class TaskServiceImpl implements TaskService { + /** + * Self object for working with proxy. + * + * @see #setSelf(TaskService) + */ + private TaskService self; + /** + * {@link Task} Repository. + */ + private final TaskRepository taskRepository; + /** + * Time management object. + */ + private final Clock clock; + + @Lazy + @Autowired + public void setSelf(TaskService self) { + this.self = self; + } + + /** + * Find all {@link Task} objects + * with {@code pageNumber} and {@code pageSize} from {@code pageInfo}. + * + * @return {@link Task} list. + */ + @Override + @Cacheable(AppCacheProperties.Types.TASKS) + @Transactional(readOnly = true) + @LazyLogger + public List findAll(PageInfo pageInfo) { + return taskRepository.findAll(pageInfo).content(); + } + + /** + * Get a {@link Task} object by specifying its id. + * + * @param id id searched {@link Task}. + * @return object of type {@link Task} with searched id. + * @throws EntityNotFoundException if {@link Task} with id not found. + */ + @Override + @Cacheable(value = AppCacheProperties.Types.TASK_BY_ID, key = "#id") + @Transactional(readOnly = true) + @LazyLogger + public Task findById(Long id) { + return taskRepository.findById(id).orElseThrow( + EntityNotFoundException.create( + MessageFormat.format( + "Task with id {0} not found!", + id + ) + ) + ); + } + + /** + * Save object model of type {@link Task}. + * + * @param model object of type {@link Task} to save. + * @return object of type {@link Task} that was saved. + */ + @Override + @Caching(evict = { + @CacheEvict(value = AppCacheProperties.Types.TASKS, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.USERS, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.USER_BY_ID, key = "#result.authorId"), + @CacheEvict(value = AppCacheProperties.Types.USER_BY_NAME, allEntries = true) + }) + @Transactional + @LazyLogger + public Task save(Task model) { + model = enrich(model); + return taskRepository.save(model); + } + + /** + * Update object model of type {@link Task} with T.id equals id. + * + * @param id id of the object to be updated. + * @param model object of type {@link Task} to update. + * @return object of type {@link Task} that was updated. + */ + @Override + @Caching(put = { + @CachePut(value = AppCacheProperties.Types.TASK_BY_ID, key = "#id") + }, evict = { + @CacheEvict(value = AppCacheProperties.Types.TASKS, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.USERS, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.USER_BY_ID, key = "#result.authorId"), + @CacheEvict(value = AppCacheProperties.Types.USER_BY_NAME, allEntries = true) + }) + @Transactional + @LazyLogger + public Task update(Long id, Task model) { + model = enrich(model, self.findById(id)); + return taskRepository.update(model); + } + + /** + * Enrich model to full version. + * + * @param model {@link Task} to enrich. + * @return {@link Task} with all fields. + */ + private Task enrich(Task model) { + return Task.builder() + .name(model.getName()) + .description(model.getDescription()) + .status(model.getStatus()) + .createdAt(LocalDateTime.now(clock)) + .updatedAt(LocalDateTime.now(clock)) + .authorId(getCurrentUserId()) + .build(); + } + + private Long getCurrentUserId() { + AppUserDetails userDetails = + (AppUserDetails) SecurityContextHolder.getContext() + .getAuthentication() + .getPrincipal(); + return userDetails.getUserId(); + } + + /** + * Enrich {@code model} to full version. + * If the {@code model} has no field values, then the values are taken + * from a previously existing entity with the same id. + * + * @param model {@link Task} with partially updated fields. + * @param taskToUpdate source entity to update {@link Task}. + * @return Updated {@link Task}. + */ + private Task enrich(Task model, Task taskToUpdate) { + return Task.builder() + .id(taskToUpdate.getId()) + .name(model.getName() == null + ? taskToUpdate.getName() + : model.getName()) + .description(model.getDescription() == null + ? taskToUpdate.getDescription() + : model.getDescription()) + .status(model.getStatus() == null + ? taskToUpdate.getStatus() + : model.getStatus()) + .createdAt(taskToUpdate.getCreatedAt()) + .updatedAt(LocalDateTime.now(clock)) + .authorId(taskToUpdate.getAuthorId()) + .build(); + } + + /** + * Delete object with User.id equals {@code id} from database. + * + * @param id id of the object to be deleted. + */ + @Override + @Caching(evict = { + @CacheEvict(value = AppCacheProperties.Types.TASK_BY_ID, key = "#id"), + @CacheEvict(value = AppCacheProperties.Types.TASKS, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.USERS, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.USER_BY_ID, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.USER_BY_NAME, allEntries = true) + }) + @Transactional + @LazyLogger + public void deleteById(Long id) { + self.findById(id); + taskRepository.deleteById(id); + } + + /** + * Delete all {@link Task} objects. + */ + @Override + @Caching(evict = { + @CacheEvict(value = AppCacheProperties.Types.TASK_BY_ID, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.TASKS, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.USERS, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.USER_BY_ID, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.USER_BY_NAME, allEntries = true) + }) + @Transactional + @LazyLogger + public void deleteAll() { + taskRepository.deleteAll(); + } + + /** + * Delete all {@link Task} objects + * for user with {@code userId}. + */ + @Override + @Caching(evict = { + @CacheEvict(value = AppCacheProperties.Types.TASK_BY_ID, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.TASKS, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.USERS, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.USER_BY_ID, key = "#userId"), + @CacheEvict(value = AppCacheProperties.Types.USER_BY_NAME, allEntries = true) + }) + @Transactional + @LazyLogger + public void deleteAllByUserId(Long userId) { + taskRepository.deleteAllByUserId(userId); + } +} diff --git a/src/main/java/com/emobile/springtodo/service/impl/UserServiceImpl.java b/src/main/java/com/emobile/springtodo/service/impl/UserServiceImpl.java new file mode 100644 index 00000000..23687951 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/impl/UserServiceImpl.java @@ -0,0 +1,310 @@ +package com.emobile.springtodo.service.impl; + +import com.emobile.springtodo.aop.LazyLogger; +import com.emobile.springtodo.config.property.cache.AppCacheProperties; +import com.emobile.springtodo.exception.AlreadyExitsException; +import com.emobile.springtodo.exception.EntityNotFoundException; +import com.emobile.springtodo.model.entity.Task; +import com.emobile.springtodo.model.entity.User; +import com.emobile.springtodo.model.util.PageInfo; +import com.emobile.springtodo.repository.UserRepository; +import com.emobile.springtodo.service.TaskService; +import com.emobile.springtodo.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.context.annotation.Lazy; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.MessageFormat; +import java.util.List; + +/** + * Service for working with entity {@link User}. + */ +@RequiredArgsConstructor +@CacheConfig +@Service +public class UserServiceImpl implements UserService { + /** + * Self object for working with proxy. + * + * @see #setSelf(UserService) + */ + private UserService self; + /** + * {@link User} Repository. + */ + private final UserRepository userRepository; + /** + * {@link Task} Service. + * To delete all task deleted user. + */ + private final TaskService taskService; + /** + * Default PasswordEncoder. + * Needed to define and update the field password in {@link User}. + * + * @see #enrich(User) + * @see #enrich(User, User) + */ + private final PasswordEncoder passwordEncoder; + + @Lazy + @Autowired + public void setSelf(UserService self) { + this.self = self; + } + + /** + * Find all {@link User} objects + * with {@code pageNumber} and {@code pageSize} from {@code pageInfo}. + * + * @return {@link User} list. + */ + @Override + @Cacheable(AppCacheProperties.Types.USERS) + @Transactional(readOnly = true) + @LazyLogger + public List findAll(PageInfo pageInfo) { + return userRepository.findAll(pageInfo).content(); + } + + /** + * Get a {@link User} object by specifying its {@code id}. + * + * @param id id searched {@link User}. + * @return object of type {@link User} with searched id. + * @throws EntityNotFoundException if {@link User} with id not found. + */ + @Override + @Cacheable(value = AppCacheProperties.Types.USER_BY_ID, key = "#id") + @Transactional(readOnly = true) + @LazyLogger + public User findById(Long id) { + return userRepository.findById(id).orElseThrow( + EntityNotFoundException.create( + MessageFormat.format( + "User with id {0} not found!", + id + ) + ) + ); + } + + /** + * Find {@link User} with User.username equals {@code username}. + * + * @param username username searched User. + * @return object of type {@link User} with searched id. + * @throws EntityNotFoundException if {@link User} with username not found. + */ + @Override + @Cacheable(value = AppCacheProperties.Types.USER_BY_NAME, key = "#username") + @Transactional(readOnly = true) + @LazyLogger + public User findByUsername(String username) { + return userRepository.findByUsername(username).orElseThrow( + EntityNotFoundException.create( + MessageFormat.format( + "User with username {0} not found!", + username + ) + ) + ); + } + + /** + * Save object model of type {@link User}. + * + * @param model object of type {@link User} to save. + * @return object of type {@link User} that was saved. + */ + @Override + @Cacheable(value = AppCacheProperties.Types.USER_BY_NAME, key = "#model.username") + @CacheEvict(value = AppCacheProperties.Types.USERS, allEntries = true) + @Transactional + @LazyLogger + public User save(User model) { + model = enrich(model); + self.checkDuplicateUsername(model.getUsername()); + self.checkDuplicateEmail(model.getEmail()); + return userRepository.save(model); + } + + /** + * Update object model of type {@link User} with T.id equals id. + * + * @param id id of the object to be updated. + * @param model object of type {@link User} to update. + * @return object of type {@link User} that was updated. + */ + @Override + @Caching(put = { + @CachePut(value = AppCacheProperties.Types.USER_BY_ID, key = "#id"), + @CachePut(value = AppCacheProperties.Types.USER_BY_NAME, key = "#result.username") + }) + @CacheEvict(value = AppCacheProperties.Types.USERS, allEntries = true) + @Transactional + @LazyLogger + public User update(Long id, User model) { + model = enrich(model, self.findById(id)); + self.checkDuplicateUsername(model.getUsername(), model.getId()); + self.checkDuplicateEmail(model.getEmail(), model.getId()); + return userRepository.update(model); + } + + /** + * Enrich model to full version. + * + * @param model {@link User} to enrich. + * @return {@link User} with all fields. + */ + private User enrich(User model) { + return User.builder() + .username(model.getUsername()) + .password(passwordEncoder.encode(model.getPassword())) + .role(model.getRole()) + .email(model.getEmail()) + .build(); + } + + /** + * Enrich {@code model} to full version. + * If the {@code model} has no field values, then the values are taken + * from a previously existing entity with the same id. + * + * @param model {@link User} with partially updated fields. + * @param userToUpdate source entity to update {@link User}. + * @return Updated {@link User}. + */ + private User enrich(User model, User userToUpdate) { + return User.builder() + .id(userToUpdate.getId()) + .username(model.getUsername() == null + ? userToUpdate.getUsername() + : model.getUsername()) + .password(model.getPassword() == null + ? passwordEncoder.encode(userToUpdate.getPassword()) + : passwordEncoder.encode(model.getPassword())) + .role(model.getRole() == null + ? userToUpdate.getRole() + : model.getRole()) + .email(model.getEmail() == null + ? userToUpdate.getEmail() + : model.getEmail()) + .taskList(model.getTaskList() == null + ? userToUpdate.getTaskList() + : model.getTaskList()) + .build(); + } + + /** + * Check duplicate username. + * For save new {@link User}. + * + * @param username username to check. + * @throws AlreadyExitsException if username already exist. + */ + @Transactional(readOnly = true) + @LazyLogger + public void checkDuplicateUsername(String username) { + if (userRepository.existsByUsername(username)) { + throw new AlreadyExitsException( + MessageFormat.format( + "Username ({0}) already exist!", + username + ) + ); + } + } + + /** + * Check duplicate username. + * For update {@link User}. + * + * @param username username to check. + * @param currentUserId current user id. + * @throws AlreadyExitsException if username already exist + * excluding {@link User} with currentUserId. + */ + @Transactional(readOnly = true) + @LazyLogger + public void checkDuplicateUsername(String username, Long currentUserId) { + if (userRepository.existsByUsernameAndIdNot(username, currentUserId)) { + throw new AlreadyExitsException( + MessageFormat.format( + "Username ({0}) already exist!", + username + ) + ); + } + } + + /** + * Check duplicate email. + * For save new {@link User}. + * + * @param email email to check. + * @throws AlreadyExitsException if email already exist. + */ + @Transactional(readOnly = true) + @LazyLogger + public void checkDuplicateEmail(String email) { + if (userRepository.existsByEmail(email)) { + throw new AlreadyExitsException( + MessageFormat.format( + "Email ({0}) already exist!", + email + ) + ); + } + } + + /** + * Check duplicate email. + * For update {@link User}. + * + * @param email email to check. + * @param currentUserId current user id. + * @throws AlreadyExitsException if email already exist + * excluding {@link User} with currentUserId. + */ + @Transactional(readOnly = true) + @LazyLogger + public void checkDuplicateEmail(String email, Long currentUserId) { + if (userRepository.existsByEmailAndIdNot(email, currentUserId)) { + throw new AlreadyExitsException( + MessageFormat.format( + "Email ({0}) already exist!", + email + ) + ); + } + } + + /** + * Delete object with User.id equals id from database. + * + * @param id id of the object to be deleted. + */ + @Override + @Caching(evict = { + @CacheEvict(value = AppCacheProperties.Types.USER_BY_NAME, allEntries = true), + @CacheEvict(value = AppCacheProperties.Types.USER_BY_ID, key = "#id"), + @CacheEvict(value = AppCacheProperties.Types.USERS, allEntries = true) + }) + @Transactional + @LazyLogger + public void deleteById(Long id) { + self.findById(id); + taskService.deleteAllByUserId(id); + userRepository.deleteById(id); + } +} diff --git a/src/main/java/com/emobile/springtodo/service/impl/security/SecurityServiceImpl.java b/src/main/java/com/emobile/springtodo/service/impl/security/SecurityServiceImpl.java new file mode 100644 index 00000000..d2818182 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/impl/security/SecurityServiceImpl.java @@ -0,0 +1,32 @@ +package com.emobile.springtodo.service.impl.security; + +import com.emobile.springtodo.aop.LazyLogger; +import com.emobile.springtodo.model.entity.User; +import com.emobile.springtodo.service.UserService; +import com.emobile.springtodo.service.security.SecurityService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * Service for work with new {@link User}. + */ +@RequiredArgsConstructor +@Service +public class SecurityServiceImpl implements SecurityService { + /** + * Service for work with {@link User} entity. + */ + private final UserService userService; + + /** + * Registration new {@link User}. + * + * @param user {@link User} for registration. + * @return registered {@link User}. + */ + @Override + @LazyLogger + public User registerNewUser(User user) { + return userService.save(user); + } +} diff --git a/src/main/java/com/emobile/springtodo/service/impl/security/UserDetailsServiceImpl.java b/src/main/java/com/emobile/springtodo/service/impl/security/UserDetailsServiceImpl.java new file mode 100644 index 00000000..071f0070 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/impl/security/UserDetailsServiceImpl.java @@ -0,0 +1,34 @@ +package com.emobile.springtodo.service.impl.security; + +import com.emobile.springtodo.aop.LazyLogger; +import com.emobile.springtodo.model.entity.User; +import com.emobile.springtodo.model.security.AppUserDetails; +import com.emobile.springtodo.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Service; + +/** + * Service for working with {@link AppUserDetails}. + */ +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + /** + * Service for working with entity {@link User}. + */ + private final UserService userService; + + /** + * Load {@link User} by username from {@link UserService}. + * + * @param username username searched {@link User}. + * @return {@link AppUserDetails} with th found user. + */ + @Override + @LazyLogger + public UserDetails loadUserByUsername(String username) { + return new AppUserDetails(userService.findByUsername(username)); + } +} diff --git a/src/main/java/com/emobile/springtodo/service/security/SecurityService.java b/src/main/java/com/emobile/springtodo/service/security/SecurityService.java new file mode 100644 index 00000000..19e16bec --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/security/SecurityService.java @@ -0,0 +1,16 @@ +package com.emobile.springtodo.service.security; + +import com.emobile.springtodo.model.entity.User; + +/** + * Service interface for work with new {@link User}. + */ +public interface SecurityService { + /** + * Registration new {@link User}. + * + * @param user {@link User} for registration + * @return registered {@link User} + */ + User registerNewUser(User user); +} diff --git a/src/main/java/com/emobile/springtodo/util/JdbcUtil.java b/src/main/java/com/emobile/springtodo/util/JdbcUtil.java new file mode 100644 index 00000000..4f289500 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/util/JdbcUtil.java @@ -0,0 +1,26 @@ +package com.emobile.springtodo.util; + +import org.springframework.jdbc.core.ResultSetExtractor; +import org.springframework.jdbc.core.RowMapper; + +import java.util.Optional; + +public class JdbcUtil { + private JdbcUtil() { + throw new IllegalStateException("Utility class"); + } + + /** + * Optional Extractor. + * + * @param mapper for map {@link T} entity + * @param entity type for mapping + * @return {@link Optional} mapped entity if ResultSet exist + */ + public static ResultSetExtractor> optionalExtractor( + RowMapper mapper) { + return rs -> rs.next() + ? Optional.of(mapper.mapRow(rs, 1)) + : Optional.empty(); + } +} 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/config/application.yml b/src/main/resources/config/application.yml new file mode 100644 index 00000000..c2fee0c1 --- /dev/null +++ b/src/main/resources/config/application.yml @@ -0,0 +1,41 @@ +spring: + application: + name: SpringToDo + datasource: + driver-class-name: org.postgresql.Driver + url: ${SERVER_URL:jdbc:postgresql://localhost:5432/spring_todo_db} + username: ${SERVER_USERNAME:postgres} + password: ${SERVER_PASS:1322} + data: + redis: + port: ${REDIS_PORT:6379} + host: ${REDIS_HOST:localhost} + flyway: + enabled: ${FLYWAY_ENABLED:true} +app: + openapi: + dev-url: http://localhost:8088 + logger: + slf4j: false + redis: + enabled: ${REDIS_ENABLED:true} + cache: + cacheNames: + - users + - userById + - userByName + - tasks + - taskById + properties: + users: + expiry: 5m + userById: + expiry: 5m + userByName: + expiry: 5m + tasks: + expiry: 30s + taskById: + expiry: 30s +server: + port: 8088 \ No newline at end of file diff --git a/src/main/resources/db/migration/V1_1__init-script.sql b/src/main/resources/db/migration/V1_1__init-script.sql new file mode 100644 index 00000000..401039ca --- /dev/null +++ b/src/main/resources/db/migration/V1_1__init-script.sql @@ -0,0 +1,30 @@ +-- +-- PostgreSQL Database: rlabs_flyway_mvn (UTF-8) +-- Initial SQL Script +-- +CREATE TABLE users +( + id bigserial not null primary key, + username varchar(255) not null, + password varchar(255) not null, + email varchar(255) not null, + role varchar(255) not null +); + +create unique index user_email_uindex + on users (email); + +create unique index user_username_uindex + on users (username); +CREATE TABLE tasks +( + id bigserial not null primary key, + name varchar(255) not null, + description varchar(255) not null, + status varchar(255) not null, + created_at timestamp not null, + updated_at timestamp not null, + author_id integer not null + constraint tasks_users_id_fk + references users +); \ 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..17548caa 100644 --- a/src/test/java/com/emobile/springtodo/SpringToDoApplicationTests.java +++ b/src/test/java/com/emobile/springtodo/SpringToDoApplicationTests.java @@ -1,13 +1,24 @@ package com.emobile.springtodo; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.Month; +import java.time.ZoneOffset; -@SpringBootTest class SpringToDoApplicationTests { + @TestConfiguration + static class CustomClockConfiguration { + private static final LocalDateTime MILLENNIUM = + LocalDateTime.of(2000, Month.JANUARY, 1, 0, 0, 0); - @Test - void contextLoads() { + @Bean + @Primary + public Clock fixedClock() { + return Clock.fixed(MILLENNIUM.toInstant(ZoneOffset.UTC), ZoneOffset.UTC); + } } - } diff --git a/src/test/java/com/emobile/springtodo/controller/AppControllerTest.java b/src/test/java/com/emobile/springtodo/controller/AppControllerTest.java new file mode 100644 index 00000000..362f9e94 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/controller/AppControllerTest.java @@ -0,0 +1,159 @@ +package com.emobile.springtodo.controller; + +import com.redis.testcontainers.RedisContainer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@Testcontainers +@DisplayName("AppControllerTest tests") +class AppControllerTest { + private static final String URL_TEMPLATE = "/api/test"; + private static final String PUBLIC_RESPONSE_MESSAGE = "Public response data."; + private static final String ADMIN_RESPONSE_MESSAGE = "Admin response data."; + private static final String USER_RESPONSE_MESSAGE = "User response data."; + private static final String ANY_RESPONSE_MESSAGE = "Authenticated response data."; + @Autowired + private MockMvc mockMvc; + + @Container + public static final PostgreSQLContainer POSTGRE_CONTAINER = + new PostgreSQLContainer<>("postgres:latest") + .withReuse(true) + .withDatabaseName("spring_todo_db"); + @Container + public static final RedisContainer REDIS_CONTAINER = + new RedisContainer(DockerImageName.parse("redis:latest")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRE_CONTAINER::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRE_CONTAINER::getUsername); + registry.add("spring.datasource.password", POSTGRE_CONTAINER::getPassword); + registry.add("spring.data.redis.host", + REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", + () -> REDIS_CONTAINER.getMappedPort(6379).toString()); + } + + @AfterAll + static void afterAll() { + POSTGRE_CONTAINER.stop(); + REDIS_CONTAINER.stop(); + } + + @Test + @WithMockUser(username = "testAdmin", roles = {"ADMIN"}) + @DisplayName("allAccess test with admin user.") + void givenAdminUser_whenAllAccessUrl_thenMessage() throws Exception { + mockMvc.perform(get(URL_TEMPLATE + "/all")) + .andExpect(content().string(PUBLIC_RESPONSE_MESSAGE)); + } + + @Test + @WithMockUser(username = "testUser") + @DisplayName("allAccess test with simple user.") + void givenSimpleUser_whenAllAccessUrl_thenMessage() throws Exception { + mockMvc.perform(get(URL_TEMPLATE + "/all")) + .andExpect(content().string(PUBLIC_RESPONSE_MESSAGE)); + } + + @Test + @WithAnonymousUser + @DisplayName("allAccess test with anonymous user.") + void givenAnonymousUser_whenAllAccessUrl_thenMessage() throws Exception { + mockMvc.perform(get(URL_TEMPLATE + "/all")) + .andExpect(content().string(PUBLIC_RESPONSE_MESSAGE)); + } + + @Test + @WithMockUser(username = "testAdmin", roles = {"ADMIN"}) + @DisplayName("adminAccess test with admin user.") + void givenAdminUser_whenAdminAccessUrl_thenMessage() throws Exception { + mockMvc.perform(get(URL_TEMPLATE + "/admin")) + .andExpect(content().string(ADMIN_RESPONSE_MESSAGE)); + } + + @Test + @WithMockUser(username = "testUser") + @DisplayName("adminAccess test with simple user.") + void givenSimpleUser_whenAdminAccessUrl_thenMessage() throws Exception { + mockMvc.perform(get(URL_TEMPLATE + "/admin")) + .andExpect(status().isForbidden()); + } + + @Test + @WithAnonymousUser + @DisplayName("adminAccess test with anonymous user.") + void givenAnonymousUser_whenAdminAccessUrl_thenMessage() throws Exception { + mockMvc.perform(get(URL_TEMPLATE + "/admin")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "testAdmin", roles = {"ADMIN"}) + @DisplayName("userAccess test with admin user.") + void givenAdminUser_whenUserAccessUrl_thenMessage() throws Exception { + mockMvc.perform(get(URL_TEMPLATE + "/user")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "testUser") + @DisplayName("anyAccess test with simple user.") + void givenSimpleUser_whenUserAccessUrl_thenMessage() throws Exception { + mockMvc.perform(get(URL_TEMPLATE + "/user")) + .andExpect(content().string(USER_RESPONSE_MESSAGE)); + } + + @Test + @WithAnonymousUser + @DisplayName("userAccess test with anonymous user.") + void givenAnonymousUser_whenUserAccessUrl_thenMessage() throws Exception { + mockMvc.perform(get(URL_TEMPLATE + "/user")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "testAdmin", roles = {"ADMIN"}) + @DisplayName("anyAccess test with admin user.") + void givenAdminUser_whenAnyAccessUrl_thenMessage() throws Exception { + mockMvc.perform(get(URL_TEMPLATE + "/any")) + .andExpect(content().string(ANY_RESPONSE_MESSAGE)); + } + + @Test + @WithMockUser(username = "testUser") + @DisplayName("anyAccess test with simple user.") + void givenSimpleUser_whenAnyAccessUrl_thenMessage() throws Exception { + mockMvc.perform(get(URL_TEMPLATE + "/any")) + .andExpect(content().string(ANY_RESPONSE_MESSAGE)); + } + + @Test + @WithAnonymousUser + @DisplayName("anyAccess test with anonymous user.") + void givenAnonymousUser_whenAnyAccessUrl_thenMessage() throws Exception { + mockMvc.perform(get(URL_TEMPLATE + "/any")) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/emobile/springtodo/controller/AuthControllerTest.java b/src/test/java/com/emobile/springtodo/controller/AuthControllerTest.java new file mode 100644 index 00000000..cd0ea37a --- /dev/null +++ b/src/test/java/com/emobile/springtodo/controller/AuthControllerTest.java @@ -0,0 +1,79 @@ +package com.emobile.springtodo.controller; + +import com.redis.testcontainers.RedisContainer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@Testcontainers +@DisplayName("AuthControllerTest tests") +class AuthControllerTest { + @Autowired + private MockMvc mockMvc; + + @Container + public static final PostgreSQLContainer POSTGRE_CONTAINER = + new PostgreSQLContainer<>("postgres:latest") + .withReuse(true) + .withDatabaseName("spring_todo_db"); + @Container + public static final RedisContainer REDIS_CONTAINER = + new RedisContainer(DockerImageName.parse("redis:latest")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRE_CONTAINER::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRE_CONTAINER::getUsername); + registry.add("spring.datasource.password", POSTGRE_CONTAINER::getPassword); + registry.add("spring.data.redis.host", + REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", + () -> REDIS_CONTAINER.getMappedPort(6379).toString()); + } + + @AfterAll + static void afterAll() { + POSTGRE_CONTAINER.stop(); + REDIS_CONTAINER.stop(); + } + + @Test + @WithAnonymousUser + @DisplayName("registerUser test: new admin user from anonymous user.") + void givenUserRequestWhenRegisterUrlThenMessage() throws Exception { + String url = "/api/auth/register"; + String requestJson = """ + { + "username": "user", + "password": "pass", + "email": "email@c.om", + "role": "ROLE_ADMIN" + }"""; + + mockMvc.perform(post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson) + ) + .andExpect(jsonPath("$.message").isString()) + .andExpect(status().isCreated()); + } +} diff --git a/src/test/java/com/emobile/springtodo/controller/TaskControllerTest.java b/src/test/java/com/emobile/springtodo/controller/TaskControllerTest.java new file mode 100644 index 00000000..4ebbb0ad --- /dev/null +++ b/src/test/java/com/emobile/springtodo/controller/TaskControllerTest.java @@ -0,0 +1,334 @@ +package com.emobile.springtodo.controller; + +import com.emobile.springtodo.model.entity.User; +import com.emobile.springtodo.model.security.AppUserDetails; +import com.emobile.springtodo.model.security.RoleType; +import com.redis.testcontainers.RedisContainer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.time.LocalDateTime; +import java.time.Month; +import java.time.format.DateTimeFormatter; +import java.util.Collections; + +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.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@Testcontainers +@DisplayName("TaskControllerTest tests") +class TaskControllerTest { + private static final String URL_TEMPLATE = "/api/task"; + @Autowired + JdbcTemplate jdbcTemplate; + @Autowired + private MockMvc mockMvc; + @Autowired + private RedisCacheManager cacheManager; + private static final LocalDateTime MILLENNIUM = LocalDateTime.of(2000, Month.JANUARY, 1, 0, 0, 0); + private static final LocalDateTime BEFORE_MILLENNIUM = MILLENNIUM.minusDays(5); + + @Container + public static final PostgreSQLContainer POSTGRE_CONTAINER = + new PostgreSQLContainer<>("postgres:latest") + .withReuse(true) + .withDatabaseName("spring_todo_db"); + @Container + public static final RedisContainer REDIS_CONTAINER = + new RedisContainer(DockerImageName.parse("redis:latest")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRE_CONTAINER::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRE_CONTAINER::getUsername); + registry.add("spring.datasource.password", POSTGRE_CONTAINER::getPassword); + registry.add("spring.data.redis.host", + REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", + () -> REDIS_CONTAINER.getMappedPort(6379).toString()); + } + + @AfterAll + static void afterAll() { + POSTGRE_CONTAINER.stop(); + REDIS_CONTAINER.stop(); + } + + @BeforeEach + void setUp() { + jdbcTemplate.update("TRUNCATE users CASCADE"); + cacheManager.getCacheNames().forEach(cacheName -> cacheManager.getCache(cacheName).clear()); + } + + @Test + @WithAnonymousUser + @DisplayName("getById test: get task data by id from anonymous user.") + void givenAnonymousUserWhenGetByIdUrlThenStatusUnauthorized() + throws Exception { + String getByIdUrl = URL_TEMPLATE + "/1"; + + mockMvc.perform(get(getByIdUrl)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser() + @DisplayName("getById test: get task data by not existed user id.") + void givenNotExistedUserIdWhenGetByIdUrlThenStatusNotFound() + throws Exception { + String getByIdUrl = URL_TEMPLATE + "/1"; + + mockMvc.perform(get(getByIdUrl)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser() + @DisplayName("getById test: get task data by existed user id.") + void givenExistedUserIdWhenGetByIdUrlThenUserResponse() + throws Exception { + long expectedId = 1L; + String getByIdUrl = URL_TEMPLATE + "/" + expectedId; + String expectedName = "name"; + String expectedDescription = "description"; + String expectedStatus = "DONE"; + long expectedAuthorId = 2L; + String sql = """ + INSERT INTO tasks(id, name, description, status, created_at, updated_at, author_id) + VALUES (?,?,?,?,?,?,?) + """; + + setDefaultAuthorUser(expectedAuthorId); + jdbcTemplate.update(sql, + expectedId, expectedName, expectedDescription, expectedStatus, + BEFORE_MILLENNIUM, MILLENNIUM, expectedAuthorId + ); + + mockMvc.perform(get(getByIdUrl)) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.name").isString()) + .andExpect(jsonPath("$.description").isString()) + .andExpect(jsonPath("$.status").isString()) + .andExpect(jsonPath("$.createdAt").isString()) + .andExpect(jsonPath("$.updatedAt").isString()) + .andExpect(jsonPath("$.authorId").isNumber()) + .andExpect(jsonPath("$.id").value(expectedId)) + .andExpect(jsonPath("$.name").value(expectedName)) + .andExpect(jsonPath("$.description").value(expectedDescription)) + .andExpect(jsonPath("$.status").value(expectedStatus)) + .andExpect(jsonPath("$.createdAt") + .value(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(BEFORE_MILLENNIUM))) + .andExpect(jsonPath("$.updatedAt") + .value(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(MILLENNIUM))) + .andExpect(jsonPath("$.authorId").value(expectedAuthorId)) + .andExpect(status().isOk()); + } + + @Test + @WithAnonymousUser + @DisplayName("save test: save task data by id from anonymous user.") + void givenAnonymousUserWhenSaveUrlThenStatusUnauthorized() + throws Exception { + mockMvc.perform(post(URL_TEMPLATE)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("save test: save task data by user id from auth user.") + void givenSaveJsonWhenSaveUrlThenStatusCreated() + throws Exception { + long expectedId = 1L; + String expectedName = "name"; + String expectedDescription = "description"; + String expectedStatus = "DONE"; + long expectedAuthorId = 2L; + String requestUserJson = """ + { + "name": "name", + "description": "description", + "status": "DONE" + }"""; + User defaultUser = new User( + expectedAuthorId, + "username", + "pass", + "email@c.om", + RoleType.ROLE_USER, + Collections.emptyList() + ); + AppUserDetails principal = new AppUserDetails(defaultUser); + setDefaultAuthorUser(expectedAuthorId); + + mockMvc.perform(post(URL_TEMPLATE) + .with(user(principal)) + .contentType(MediaType.APPLICATION_JSON) + .content(requestUserJson) + ) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.name").isString()) + .andExpect(jsonPath("$.description").isString()) + .andExpect(jsonPath("$.status").isString()) + .andExpect(jsonPath("$.createdAt").isString()) + .andExpect(jsonPath("$.updatedAt").isString()) + .andExpect(jsonPath("$.authorId").isNumber()) + .andExpect(jsonPath("$.id").value(expectedId)) + .andExpect(jsonPath("$.name").value(expectedName)) + .andExpect(jsonPath("$.description").value(expectedDescription)) + .andExpect(jsonPath("$.status").value(expectedStatus)) + .andExpect(jsonPath("$.createdAt") + .value(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(MILLENNIUM))) + .andExpect(jsonPath("$.updatedAt") + .value(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(MILLENNIUM))) + .andExpect(jsonPath("$.authorId").value(expectedAuthorId)) + .andExpect(status().isCreated()); + } + + @Test + @WithAnonymousUser + @DisplayName("update test: update task data by id from anonymous user.") + void givenAnonymousUserWhenUpdateUrlThenStatusUnauthorized() + throws Exception { + String updateUrl = URL_TEMPLATE + "/1"; + + mockMvc.perform(put(updateUrl)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser() + @DisplayName("update test: update task data by user id from auth user.") + void givenInsertJsonWhenUpdateUrlThenTaskResponse() + throws Exception { + long expectedId = 1L; + String updateUrl = URL_TEMPLATE + "/" + expectedId; + String expectedName = "name"; + String expectedDescription = "description"; + String expectedStatus = "DONE"; + long expectedAuthorId = 2L; + String requestUserJson = """ + { + "name": "name", + "description": "description", + "status": "DONE" + }"""; + String sql = """ + INSERT INTO tasks(id, name, description, status, created_at, updated_at, author_id) + VALUES (?,?,?,?,?,?,?) + """; + setDefaultAuthorUser(expectedAuthorId); + + jdbcTemplate.update(sql, + expectedId, expectedName, expectedDescription, expectedStatus, + BEFORE_MILLENNIUM, MILLENNIUM, expectedAuthorId + ); + + mockMvc.perform(put(updateUrl) + .contentType(MediaType.APPLICATION_JSON) + .content(requestUserJson) + ) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.name").isString()) + .andExpect(jsonPath("$.description").isString()) + .andExpect(jsonPath("$.status").isString()) + .andExpect(jsonPath("$.createdAt").isString()) + .andExpect(jsonPath("$.updatedAt").isString()) + .andExpect(jsonPath("$.authorId").isNumber()) + .andExpect(jsonPath("$.id").value(expectedId)) + .andExpect(jsonPath("$.name").value(expectedName)) + .andExpect(jsonPath("$.description").value(expectedDescription)) + .andExpect(jsonPath("$.status").value(expectedStatus)) + .andExpect(jsonPath("$.createdAt") + .value(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(BEFORE_MILLENNIUM))) + .andExpect(jsonPath("$.updatedAt") + .value(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(MILLENNIUM))) + .andExpect(jsonPath("$.authorId").value(expectedAuthorId)) + .andExpect(status().isOk()); + } + + @Test + @WithAnonymousUser + @DisplayName("delete test: delete user data by id from anonymous user.") + void givenAnonymousUserWhenDeleteUrlThenStatusUnauthorized() + throws Exception { + String deleteUrl = URL_TEMPLATE + "/1"; + + mockMvc.perform(delete(deleteUrl)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser() + @DisplayName("delete test: delete user data by not existed id.") + void givenNotExistedUserIdWhenDeleteUrlThenStatusNotFound() + throws Exception { + String deleteUrl = URL_TEMPLATE + "/1"; + + mockMvc.perform(delete(deleteUrl)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser() + @DisplayName("delete test: delete user data by existed id from auth user.") + void givenExistedUserIdWhenDeleteUrlThenStatusNoContent() + throws Exception { + long expectedId = 1L; + String deleteUrl = URL_TEMPLATE + "/" + expectedId; + String expectedName = "name"; + String expectedDescription = "description"; + String expectedStatus = "DONE"; + long expectedAuthorId = 2L; + String sql = """ + INSERT INTO tasks(id, name, description, status, created_at, updated_at, author_id) + VALUES (?,?,?,?,?,?,?) + """; + + setDefaultAuthorUser(expectedAuthorId); + jdbcTemplate.update(sql, + expectedId, expectedName, expectedDescription, expectedStatus, + BEFORE_MILLENNIUM, MILLENNIUM, expectedAuthorId + ); + mockMvc.perform(delete(deleteUrl)) + .andExpect(status().isNoContent()); + } + + private void setDefaultAuthorUser(long expectedId) { + String expectedUsername = "username"; + String expectedPass = "pass"; + String expectedEmail = "email@c.om"; + String expectedRole = "ROLE_USER"; + String sql = """ + INSERT INTO users(id, username, password, email, role) + VALUES (?,?,?,?,?) + """; + jdbcTemplate.update(sql, + expectedId, expectedUsername, expectedPass, + expectedEmail, expectedRole + ); + } +} diff --git a/src/test/java/com/emobile/springtodo/controller/UserControllerTest.java b/src/test/java/com/emobile/springtodo/controller/UserControllerTest.java new file mode 100644 index 00000000..db3bff9b --- /dev/null +++ b/src/test/java/com/emobile/springtodo/controller/UserControllerTest.java @@ -0,0 +1,240 @@ +package com.emobile.springtodo.controller; + +import com.redis.testcontainers.RedisContainer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@Testcontainers +@DisplayName("UserControllerTest tests") +class UserControllerTest { + private static final String URL_TEMPLATE = "/api/user"; + @Autowired + JdbcTemplate jdbcTemplate; + @MockitoBean + PasswordEncoder passwordEncoder; + @Autowired + private MockMvc mockMvc; + @Autowired + private RedisCacheManager cacheManager; + + @Container + public static final PostgreSQLContainer POSTGRE_CONTAINER = + new PostgreSQLContainer<>("postgres:latest") + .withReuse(true) + .withDatabaseName("spring_todo_db"); + @Container + public static final RedisContainer REDIS_CONTAINER = + new RedisContainer(DockerImageName.parse("redis:latest")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRE_CONTAINER::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRE_CONTAINER::getUsername); + registry.add("spring.datasource.password", POSTGRE_CONTAINER::getPassword); + registry.add("spring.data.redis.host", + REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", + () -> REDIS_CONTAINER.getMappedPort(6379).toString()); + } + + @AfterAll + static void afterAll() { + POSTGRE_CONTAINER.stop(); + REDIS_CONTAINER.stop(); + } + + @BeforeEach + void setUp() { + jdbcTemplate.update("TRUNCATE users CASCADE"); + cacheManager.getCacheNames().forEach(cacheName -> cacheManager.getCache(cacheName).clear()); + } + + @Test + @WithAnonymousUser + @DisplayName("getById test: get user data by id from anonymous user.") + void givenAnonymousUserWhenGetByIdUrlThenStatusUnauthorized() + throws Exception { + String getByIdUrl = URL_TEMPLATE + "/1"; + + mockMvc.perform(get(getByIdUrl)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser() + @DisplayName("getById test: get user data by not existed user id.") + void givenNotExistedUserIdWhenGetByIdUrlThenStatusNotFound() + throws Exception { + String getByIdUrl = URL_TEMPLATE + "/1"; + + mockMvc.perform(get(getByIdUrl)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser() + @DisplayName("getById test: get user data by existed user id.") + void givenExistedUserIdWhenGetByIdUrlThenUserResponse() + throws Exception { + long expectedId = 1L; + String getByIdUrl = URL_TEMPLATE + "/" + expectedId; + String expectedUsername = "username"; + String expectedPass = "encodedPass"; + String expectedEmail = "email@c.om"; + String expectedRole = "ROLE_ADMIN"; + String sql = """ + INSERT INTO users(id, username, password, email, role) + VALUES (?,?,?,?,?) + """; + jdbcTemplate.update(sql, + expectedId, expectedUsername, expectedPass, + expectedEmail, expectedRole + ); + + mockMvc.perform(get(getByIdUrl)) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.username").isString()) + .andExpect(jsonPath("$.password").isString()) + .andExpect(jsonPath("$.email").isString()) + .andExpect(jsonPath("$.role").isString()) + .andExpect(jsonPath("$.id").value(expectedId)) + .andExpect(jsonPath("$.username").value(expectedUsername)) + .andExpect(jsonPath("$.password").value(expectedPass)) + .andExpect(jsonPath("$.email").value(expectedEmail)) + .andExpect(jsonPath("$.role").value(expectedRole)) + .andExpect(status().isOk()); + } + + @Test + @WithAnonymousUser + @DisplayName("update test: update user data by id from anonymous user.") + void givenAnonymousUserWhenUpdateUrlThenStatusUnauthorized() + throws Exception { + String updateUrl = URL_TEMPLATE + "/1"; + + mockMvc.perform(put(updateUrl)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser() + @DisplayName("update test: update user data by user id from auth user.") + void givenInsertJsonWhenUpdateUrlThenUserResponse() + throws Exception { + long expectedId = 1L; + String updateUrl = URL_TEMPLATE + "/" + expectedId; + String oldUsername = "username"; + String oldPass = "password"; + String oldEmail = "email@c.om"; + String oldRole = "ROLE_ADMIN"; + String expectedUsername = "user"; + String expectedPass = "encodedPass"; + String requestUserJson = """ + { + "username": "user", + "password": "pass", + "email" : "email@c.om", + "roles": "ROLE_ADMIN" + }"""; + String sql = """ + INSERT INTO users(id, username, password, email, role) + VALUES (?,?,?,?,?) + """;// + jdbcTemplate.update(sql, + expectedId, oldUsername, oldPass, + oldEmail, oldRole + ); + + when(passwordEncoder.encode("pass")) + .thenReturn(expectedPass); + + mockMvc.perform(put(updateUrl) + .contentType(MediaType.APPLICATION_JSON) + .content(requestUserJson) + ) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.username").isString()) + .andExpect(jsonPath("$.password").isString()) + .andExpect(jsonPath("$.email").isString()) + .andExpect(jsonPath("$.role").isString()) + .andExpect(jsonPath("$.id").value(expectedId)) + .andExpect(jsonPath("$.username").value(expectedUsername)) + .andExpect(jsonPath("$.password").value(expectedPass)) + .andExpect(jsonPath("$.email").value(oldEmail)) + .andExpect(jsonPath("$.role").value(oldRole)) + .andExpect(status().isOk()); + } + + @Test + @WithAnonymousUser + @DisplayName("delete test: delete user data by id from anonymous user.") + void givenAnonymousUserWhenDeleteUrlThenStatusUnauthorized() + throws Exception { + String deleteUrl = URL_TEMPLATE + "/1"; + + mockMvc.perform(delete(deleteUrl)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser() + @DisplayName("delete test: delete user data by not existed id.") + void givenNotExistedUserIdWhenDeleteUrlThenStatusNotFound() + throws Exception { + String deleteUrl = URL_TEMPLATE + "/1"; + + mockMvc.perform(delete(deleteUrl)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser() + @DisplayName("delete test: delete user data by existed id from auth user.") + void givenExistedUserIdWhenDeleteUrlThenStatusNoContent() + throws Exception { + long expectedId = 1L; + String deleteUrl = URL_TEMPLATE + "/" + expectedId; + String expectedUsername = "username"; + String expectedPass = "encodedPass"; + String expectedEmail = "email@c.om"; + String expectedRole = "ROLE_ADMIN"; + String sql = """ + INSERT INTO users(id, username, password, email, role) + VALUES (?,?,?,?,?) + """; + jdbcTemplate.update(sql, + expectedId, expectedUsername, expectedPass, + expectedEmail, expectedRole + ); + mockMvc.perform(delete(deleteUrl)) + .andExpect(status().isNoContent()); + } +} + diff --git a/src/test/java/com/emobile/springtodo/repository/impl/TaskRepositoryImplTest.java b/src/test/java/com/emobile/springtodo/repository/impl/TaskRepositoryImplTest.java new file mode 100644 index 00000000..cd45bfda --- /dev/null +++ b/src/test/java/com/emobile/springtodo/repository/impl/TaskRepositoryImplTest.java @@ -0,0 +1,197 @@ +package com.emobile.springtodo.repository.impl; + +import com.emobile.springtodo.model.entity.Task; +import com.emobile.springtodo.model.entity.TaskStatus; +import com.emobile.springtodo.model.entity.User; +import com.emobile.springtodo.model.security.RoleType; +import com.emobile.springtodo.model.util.Page; +import com.emobile.springtodo.model.util.PageInfo; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.LocalDateTime; +import java.time.Month; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Testcontainers +@DisplayName("TaskRepositoryImplTest Tests") +class TaskRepositoryImplTest { + private TaskRepositoryImpl repository; + @Autowired + private JdbcTemplate jdbcTemplate; + private static final LocalDateTime MILLENNIUM = LocalDateTime.of(2000, Month.JANUARY, 1, 0, 0, 0); + private static final LocalDateTime BEFORE_MILLENNIUM = MILLENNIUM.minusDays(5); + + @Container + static PostgreSQLContainer postgreContainer = + new PostgreSQLContainer<>("postgres:latest") + .withReuse(true); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgreContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgreContainer::getUsername); + registry.add("spring.datasource.password", postgreContainer::getPassword); + } + + @AfterAll + static void afterAll() { + postgreContainer.stop(); + } + + @BeforeEach + void setUp() { + repository = new TaskRepositoryImpl(jdbcTemplate); + jdbcTemplate.update("TRUNCATE tasks CASCADE"); + jdbcTemplate.update("TRUNCATE users CASCADE"); + } + + @Test + @DisplayName("findAll test: get all user data.") + void givenPageInfoWhenGetAllThenListUser() { + PageInfo pageInfo = new PageInfo(5, 1); + Task test1 = new Task(2L, "name", "des", + TaskStatus.TODO, BEFORE_MILLENNIUM, MILLENNIUM, 3L); + Task test2 = new Task(4L, "name", "des", + TaskStatus.TODO, BEFORE_MILLENNIUM, MILLENNIUM, 2L); + Page expected = new Page<>(List.of(test1, test2)); + User testAuthor = new User(3L, "user", "pass1", + "email1@co.m", RoleType.ROLE_USER, List.of()); + User testAuthor2 = new User(2L, "user2", "pass1", + "email2@co.m", RoleType.ROLE_USER, List.of()); + addToDb(testAuthor); + addToDb(testAuthor2); + addToDb(test1); + addToDb(test2); + + Page actual = repository.findAll(pageInfo); + + assertEquals(expected, actual); + } + + @Test + @DisplayName("findById test: get user data by id.") + void givenExistingIdWhenGetByIdThenUser() { + Long taskId = 2L; + Task test1 = new Task(taskId, "name", "des", + TaskStatus.TODO, BEFORE_MILLENNIUM, MILLENNIUM, 4L); + User testAuthor = new User(4L, "user", "pass1", + "email1@co.m", RoleType.ROLE_USER, List.of()); + addToDb(testAuthor); + addToDb(test1); + + Optional actual = repository.findById(taskId); + + assertTrue(actual.isPresent()); + assertEquals(test1, actual.get()); + } + + @Test + @DisplayName("findById test: try to get task data by not existing id.") + void givenNotExistingIdWhenGetByIdThenThrow() { + Long taskId = 1L; + + Optional actual = repository.findById(taskId); + + assertTrue(actual.isEmpty()); + } + + @Test + @DisplayName("update test: send task data to repository.") + void givenTaskWhenUpdateThenUpdatedTask() { + Task test1 = new Task(2L, "name", "des", + TaskStatus.TODO, BEFORE_MILLENNIUM, BEFORE_MILLENNIUM, 3L); + Task expected = new Task(2L, "name2", "des2", + TaskStatus.DONE, BEFORE_MILLENNIUM, MILLENNIUM, 3L); + User testAuthor = new User(3L, "user", "pass1", + "email1@co.m", RoleType.ROLE_USER, List.of()); + addToDb(testAuthor); + addToDb(test1); + + repository.update(expected); + + assertTrue(existsInDb(expected)); + } + + @Test + @DisplayName("delete test: delete task data message to repository.") + void givenTaskIdWhenDeleteThenVoid() { + Long taskId = 2L; + Task test1 = new Task(taskId, "name", "des", + TaskStatus.TODO, BEFORE_MILLENNIUM, BEFORE_MILLENNIUM, 3L); + User testAuthor = new User(3L, "user", "pass1", + "email1@co.m", RoleType.ROLE_USER, List.of()); + addToDb(testAuthor); + addToDb(test1); + + repository.deleteById(taskId); + assertFalse(existsInDb(test1)); + } + + private void addToDb(User user) { + String sql = """ + INSERT INTO users(id, username, password, email, role) + VALUES (?,?,?,?,?) + """; + jdbcTemplate.update(sql, + user.getId(), user.getUsername(), user.getPassword(), + user.getEmail(), user.getRole().name() + ); + } + + private void addToDb(Task task) { + String sql = """ + INSERT INTO tasks(id, name, description, status, created_at, updated_at, author_id) + VALUES (?,?,?,?,?,?,?) + """; + jdbcTemplate.update(sql, + task.getId(), task.getName(), task.getDescription(), + task.getStatus().name(), task.getCreatedAt(), + task.getUpdatedAt(), task.getAuthorId() + ); + } + + private boolean existsInDb(Task task) { + String sql = """ + SELECT EXISTS (SELECT * + FROM tasks + WHERE name = ? + AND description = ? + AND status = ? + AND created_at = ? + AND updated_at = ? + AND author_id = ?) + AS result + """; + return jdbcTemplate.queryForObject(sql, getExistsMapper(), + task.getName(), task.getDescription(), + task.getStatus().name(), task.getCreatedAt(), + task.getUpdatedAt(), task.getAuthorId() + ); + } + + private RowMapper getExistsMapper() { + return (resultSet, rowNum) -> + resultSet.getString("result") + .equals("t"); + } +} diff --git a/src/test/java/com/emobile/springtodo/repository/impl/UserRepositoryImplTest.java b/src/test/java/com/emobile/springtodo/repository/impl/UserRepositoryImplTest.java new file mode 100644 index 00000000..d3a928cd --- /dev/null +++ b/src/test/java/com/emobile/springtodo/repository/impl/UserRepositoryImplTest.java @@ -0,0 +1,218 @@ +package com.emobile.springtodo.repository.impl; + +import com.emobile.springtodo.model.entity.Task; +import com.emobile.springtodo.model.entity.TaskStatus; +import com.emobile.springtodo.model.entity.User; +import com.emobile.springtodo.model.security.RoleType; +import com.emobile.springtodo.model.util.Page; +import com.emobile.springtodo.model.util.PageInfo; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.LocalDateTime; +import java.time.Month; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Testcontainers +@DisplayName("UserRepositoryImpl Tests") +class UserRepositoryImplTest { + private UserRepositoryImpl repository; + @Autowired + private JdbcTemplate jdbcTemplate; + private static final LocalDateTime MILLENNIUM = LocalDateTime.of(2000, Month.JANUARY, 1, 0, 0, 0); + private static final LocalDateTime BEFORE_MILLENNIUM = MILLENNIUM.minusDays(5); + @Container + static PostgreSQLContainer postgreContainer = + new PostgreSQLContainer<>("postgres:latest") + .withReuse(true); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgreContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgreContainer::getUsername); + registry.add("spring.datasource.password", postgreContainer::getPassword); + } + + @AfterAll + static void afterAll() { + postgreContainer.stop(); + } + + @BeforeEach + void setUp() { + repository = new UserRepositoryImpl(jdbcTemplate); + jdbcTemplate.update("TRUNCATE users CASCADE"); + } + + @Test + @DisplayName("findAll test: get all user data.") + void givenPageInfoWhenGetAllThenListUser() { + PageInfo pageInfo = new PageInfo(5, 1); + User test1 = new User(2L, "test1", "pass1", + "email1@co.m", RoleType.ROLE_USER, List.of()); + User test2 = new User(3L, "test2", "pass2", + "email2@co.m", RoleType.ROLE_ADMIN, List.of()); + Page expected = new Page<>(List.of(test1, test2)); + addToDb(test1); + addToDb(test2); + + Page actual = repository.findAll(pageInfo); + + assertEquals(expected, actual); + } + + @Test + @DisplayName("findById test: get user data by id.") + void givenExistingIdWhenGetByIdThenUser() { + Long userId = 2L; + Task testTask = new Task(2L, "name", "des", + TaskStatus.TODO, BEFORE_MILLENNIUM, MILLENNIUM, userId); + User test1 = new User(userId, "test1", "pass1", + "email1@co.m", RoleType.ROLE_USER, List.of(testTask)); + addToDb(test1); + addToDb(testTask); + + Optional actual = repository.findById(userId); + + assertTrue(actual.isPresent()); + assertEquals(test1, actual.get()); + } + + @Test + @DisplayName("findById test: try to get user data by not existing id.") + void givenNotExistingIdWhenGetByIdThenThrow() { + Long userId = 1L; + + Optional actual = repository.findById(userId); + + assertTrue(actual.isEmpty()); + } + + @Test + @DisplayName("findByUsername test: get user data by name.") + void givenExistingNameWhenGetByIdThenUser() { + String userUsername = "user"; + Task testTask = new Task(2L, "name", "des", + TaskStatus.TODO, BEFORE_MILLENNIUM, MILLENNIUM, 2L); + User test1 = new User(2L, userUsername, "pass1", + "email1@co.m", RoleType.ROLE_USER, List.of(testTask)); + addToDb(test1); + addToDb(testTask); + + Optional actual = repository.findByUsername(userUsername); + + assertTrue(actual.isPresent()); + assertEquals(test1, actual.get()); + } + + @Test + @DisplayName("findByUsername test: get user data by name.") + void givenNotExistingNameWhenGetByIdThenUser() { + String notExistName = "notExistName"; + + Optional actual = repository.findByUsername(notExistName); + + assertTrue(actual.isEmpty()); + } + + @Test + @DisplayName("save test: send user data to repository.") + void givenUserWhenSendUserToDbThenSavedUser() { + User userToSave = new User(null, "user", "pass", + "email2@co.m", RoleType.ROLE_USER, List.of()); + + repository.save(userToSave); + + assertTrue(existsInDb(userToSave)); + } + + @Test + @DisplayName("update test: send user data to repository.") + void givenUserWhenUpdateThenUpdatedUser() { + User test1 = new User(2L, "user", "pass1", + "email1@co.m", RoleType.ROLE_USER, List.of()); + User expected = new User(2L, "user", "pass", + "email2@co.m", RoleType.ROLE_USER, List.of()); + addToDb(test1); + + repository.update(expected); + + assertTrue(existsInDb(expected)); + } + + @Test + @DisplayName("delete test: delete user data message to repository.") + void givenUserIdWhenDeleteThenVoid() { + Long userId = 1L; + User test1 = new User(userId, "user", "pass1", + "email1@co.m", RoleType.ROLE_USER, List.of()); + addToDb(test1); + + repository.deleteById(userId); + assertFalse(existsInDb(test1)); + } + + private void addToDb(User user) { + String sql = """ + INSERT INTO users(id, username, password, email, role) + VALUES (?,?,?,?,?) + """; + jdbcTemplate.update(sql, + user.getId(), user.getUsername(), user.getPassword(), + user.getEmail(), user.getRole().name() + ); + } + + private void addToDb(Task task) { + String sql = """ + INSERT INTO tasks(id, name, description, status, created_at, updated_at, author_id) + VALUES (?,?,?,?,?,?,?) + """; + jdbcTemplate.update(sql, + task.getId(), task.getName(), task.getDescription(), + task.getStatus().name(), task.getCreatedAt(), + task.getUpdatedAt(), task.getAuthorId() + ); + } + + private boolean existsInDb(User user) { + String sql = """ + SELECT EXISTS (SELECT * + FROM users + WHERE username = ? + AND password = ? + AND email = ? + AND role = ?) + AS result + """; + return jdbcTemplate.queryForObject(sql, getExistsMapper(), + user.getUsername(), user.getPassword(), + user.getEmail(), user.getRole().name() + ); + } + + private RowMapper getExistsMapper() { + return (resultSet, rowNum) -> + resultSet.getString("result") + .equals("t"); + } +} diff --git a/src/test/java/com/emobile/springtodo/service/impl/TaskServiceImplTest.java b/src/test/java/com/emobile/springtodo/service/impl/TaskServiceImplTest.java new file mode 100644 index 00000000..13eea52f --- /dev/null +++ b/src/test/java/com/emobile/springtodo/service/impl/TaskServiceImplTest.java @@ -0,0 +1,306 @@ +package com.emobile.springtodo.service.impl; + +import com.emobile.springtodo.exception.EntityNotFoundException; +import com.emobile.springtodo.model.entity.Task; +import com.emobile.springtodo.model.entity.TaskStatus; +import com.emobile.springtodo.model.entity.User; +import com.emobile.springtodo.model.security.AppUserDetails; +import com.emobile.springtodo.model.security.RoleType; +import com.emobile.springtodo.model.util.Page; +import com.emobile.springtodo.model.util.PageInfo; +import com.emobile.springtodo.repository.TaskRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.Month; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserServiceImplTest Tests") +class TaskServiceImplTest { + private TaskServiceImpl taskService; + @Mock + private TaskRepository taskRepository; + private static final LocalDateTime MILLENNIUM = LocalDateTime.of(2000, Month.JANUARY, 1, 0, 0, 0); + private static final LocalDateTime BEFORE_MILLENNIUM = MILLENNIUM.minusDays(5); + private final Clock clock = Clock.fixed(MILLENNIUM.toInstant(ZoneOffset.UTC), ZoneOffset.UTC); + + + @BeforeEach + void setUp() { + taskService = new TaskServiceImpl(taskRepository, clock); + taskService.setSelf(taskService); + } + + @Test + @DisplayName("findAll test: get all user data.") + void givenWhenGetAllThenListUser() { + List userList = new ArrayList<>(List.of( + new Task(), + new Task() + )); + PageInfo pageInfo = new PageInfo(0, 10); + + when(taskRepository.findAll(pageInfo)) + .thenReturn(new Page<>(userList)); + + List actual = taskService.findAll(pageInfo); + + assertEquals(userList.size(), actual.size()); + verify(taskRepository, times(1)) + .findAll(pageInfo); + } + + @Test + @DisplayName("findById test: get user data by id.") + void givenExistingIdWhenGetByIdThenUser() { + Long userId = 1L; + Task defaultTask = new Task( + 1L, + "name", + "description", + TaskStatus.DONE, + LocalDateTime.of(2, 2, 2, 2, 2), + LocalDateTime.of(2, 2, 2, 2, 2), + 2L + ); + + when(taskRepository.findById(userId)) + .thenReturn(Optional.of(defaultTask)); + + Task actual = taskService.findById(userId); + + assertEquals(defaultTask, actual); + verify(taskRepository, times(1)) + .findById(any()); + } + + @Test + @DisplayName("findById test: try to get user data by not existing id.") + void givenNotExistingIdWhenGetByIdThenThrow() { + Long userId = 1L; + + when(taskRepository.findById(userId)) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> taskService.findById(userId), + " index is incorrect." + ); + verify(taskRepository, times(1)) + .findById(any()); + } + + @Test + @DisplayName("save test: send user data to repository.") + void givenUserWhenSendUserToDbThenSavedUser() { + Long userPrincipalId = 10L; + Task taskToSave = new Task( + null, + "name", + "description", + TaskStatus.DONE, + null, + null, + null + ); + Task expected = new Task( + null, + "name", + "description", + TaskStatus.DONE, + MILLENNIUM, + MILLENNIUM, + userPrincipalId + ); + User defaultUser = new User( + userPrincipalId, + "username", + "password", + "email", + RoleType.ROLE_USER, + Collections.emptyList() + ); + AppUserDetails principal = new AppUserDetails(defaultUser); + Authentication auth = + new UsernamePasswordAuthenticationToken( + principal, "password", principal.getAuthorities() + ); + SecurityContext securityContext = mock(SecurityContext.class); + SecurityContextHolder.setContext(securityContext); + + when(securityContext.getAuthentication()).thenReturn(auth); + when(taskRepository.save(expected)) + .thenReturn(expected); + + Task actual = taskService.save(taskToSave); + + assertEquals(expected, actual); + verify(taskRepository, times(1)) + .save(any()); + } + + @Test + @DisplayName("update test: with partially filled User data.") + void givenPartiallyFilledUserUpdateThenUpdatedUser() { + Long userId = 1L; + Task partiallyFilledTask = new Task( + null, + "name", + null, + TaskStatus.TODO, + null, + null, + 2L + ); + Task taskToUpdate = new Task( + 1L, + "name", + "description", + TaskStatus.DONE, + BEFORE_MILLENNIUM, + BEFORE_MILLENNIUM, + 2L + ); + Task expected = new Task( + 1L, + "name", + "description", + TaskStatus.TODO, + BEFORE_MILLENNIUM, + MILLENNIUM, + 2L + ); + + + when(taskRepository.findById(userId)) + .thenReturn(Optional.of(taskToUpdate)); + when(taskRepository.update(expected)) + .thenReturn(expected); + + Task actual = taskService.update(userId, partiallyFilledTask); + + assertEquals(expected, actual); + verify(taskRepository, times(1)) + .update(expected); + verify(taskRepository, times(1)) + .findById(1L); + } + + @Test + @DisplayName("update test: with filled User data.") + void givenFilledUserAndUserIdWhenUpdateThenUpdatedUser() { + Long userId = 1L; + Task taskToUpdate = new Task( + 1L, + "name", + "description", + TaskStatus.DONE, + BEFORE_MILLENNIUM, + BEFORE_MILLENNIUM, + 2L + ); + Task expected = new Task( + 1L, + "name", + "description", + TaskStatus.DONE, + BEFORE_MILLENNIUM, + MILLENNIUM, + 2L + ); + + when(taskRepository.findById(userId)) + .thenReturn(Optional.of(expected)); + when(taskRepository.update(expected)) + .thenReturn(expected); + + Task actual = taskService.update(userId, taskToUpdate); + + assertEquals(expected, actual); + verify(taskRepository, times(1)) + .update(expected); + verify(taskRepository, times(1)) + .findById(1L); + } + + @Test + @DisplayName("update test: try update with not existed user id.") + void givenUserAndNotExistedUserIdWhenUpdateThenUpdatedUser() { + Long notExistedUserId = 1L; + LocalDateTime creationTime = LocalDateTime.of(1999, Month.DECEMBER, 10, 0, 0, 0); + Task taskToUpdate = new Task( + 1L, + "name", + "description", + TaskStatus.DONE, + creationTime, + creationTime, + 2L + ); + + when(taskRepository.findById(notExistedUserId)) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> taskService.update(notExistedUserId, taskToUpdate), + "UserId is incorrect." + ); + verify(taskRepository, times(0)) + .update(any()); + verify(taskRepository, times(1)) + .findById(any()); + } + + @Test + @DisplayName("delete test: delete user data message to repository.") + void givenExistedUserIdWhenDeleteThenVoid() { + Long existedTaskId = 1L; + + when(taskRepository.findById(existedTaskId)) + .thenReturn(Optional.of(new Task())); + taskService.deleteById(existedTaskId); + + verify(taskRepository, times(1)) + .findById(existedTaskId); + verify(taskRepository, times(1)) + .deleteById(existedTaskId); + } + + @Test + @DisplayName("delete test: delete task data message to repository.") + void givenNotExistedUserIdWhenDeleteThenVoid() { + Long notExistedUserId = 1L; + + assertThrows(EntityNotFoundException.class, + () -> taskService.deleteById(notExistedUserId), + "UserId is incorrect." + ); + verify(taskRepository, times(1)) + .findById(notExistedUserId); + verify(taskRepository, times(0)) + .deleteById(notExistedUserId); + } +} \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/service/impl/UserServiceImplTest.java b/src/test/java/com/emobile/springtodo/service/impl/UserServiceImplTest.java new file mode 100644 index 00000000..45c0a174 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/service/impl/UserServiceImplTest.java @@ -0,0 +1,302 @@ +package com.emobile.springtodo.service.impl; + +import com.emobile.springtodo.exception.EntityNotFoundException; +import com.emobile.springtodo.model.entity.User; +import com.emobile.springtodo.model.security.RoleType; +import com.emobile.springtodo.model.util.Page; +import com.emobile.springtodo.model.util.PageInfo; +import com.emobile.springtodo.repository.UserRepository; +import com.emobile.springtodo.service.TaskService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserServiceImplTest Tests") +class UserServiceImplTest { + private UserServiceImpl userService; + @Mock + private UserRepository userRepository; + @Mock + private TaskService taskService; + @Mock + private PasswordEncoder passwordEncoder; + + + @BeforeEach + void setUp() { + userService = new UserServiceImpl(userRepository, taskService, passwordEncoder); + userService.setSelf(userService); + } + + @Test + @DisplayName("findAll test: get all user data.") + void givenWhenGetAllThenListUser() { + List userList = new ArrayList<>(List.of( + new User(), + new User() + )); + PageInfo pageInfo = new PageInfo(0, 10); + + when(userRepository.findAll(pageInfo)) + .thenReturn(new Page<>(userList)); + + List actual = userService.findAll(pageInfo); + + assertEquals(userList.size(), actual.size()); + verify(userRepository, times(1)) + .findAll(pageInfo); + } + + @Test + @DisplayName("findById test: get user data by id.") + void givenExistingIdWhenGetByIdThenUser() { + Long userId = 1L; + User defaultUser = new User( + 1L, + "user", + "pass", + "email", + RoleType.ROLE_USER, + Collections.emptyList() + ); + + when(userRepository.findById(userId)) + .thenReturn(Optional.of(defaultUser)); + + User actual = userService.findById(userId); + + assertEquals(defaultUser, actual); + verify(userRepository, times(1)) + .findById(any()); + } + + @Test + @DisplayName("findById test: try to get user data by not existing id.") + void givenNotExistingIdWhenGetByIdThenThrow() { + Long userId = 1L; + + when(userRepository.findById(userId)) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> userService.findById(userId), + " index is incorrect." + ); + verify(userRepository, times(1)) + .findById(any()); + } + + @Test + @DisplayName("findByUsername test: get user data by name.") + void givenExistingNameWhenGetByIdThenUser() { + String userUsername = "user"; + User defaultUser = new User( + 1L, + "user", + "pass", + "email", + RoleType.ROLE_USER, + Collections.emptyList() + ); + + when(userRepository.findByUsername(userUsername)) + .thenReturn(Optional.of(defaultUser)); + + User actual = userService.findByUsername(userUsername); + + assertEquals(defaultUser, actual); + verify(userRepository, times(1)) + .findByUsername(any()); + } + + @Test + @DisplayName("findByUsername test: get user data by name.") + void givenNotExistingNameWhenGetByIdThenUser() { + String notExistName = "notExistName"; + + when(userRepository.findByUsername(notExistName)) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> userService.findByUsername(notExistName), + "username is incorrect" + ); + verify(userRepository, times(1)) + .findByUsername(any()); + } + + @Test + @DisplayName("save test: send user data to repository.") + void givenUserWhenSendUserToDbThenSavedUser() { + String defaultPass = "pass"; + User userToSave = new User( + null, + "user", + defaultPass, + "email", + RoleType.ROLE_USER, + Collections.emptyList() + ); + + when(userRepository.save(userToSave)) + .thenReturn(userToSave); + when(passwordEncoder.encode(defaultPass)) + .thenReturn(defaultPass); + + User actual = userService.save(userToSave); + + assertEquals(userToSave, actual); + verify(userRepository, times(1)) + .save(any()); + } + + @Test + @DisplayName("update test: with partially filled User data.") + void givenPartiallyFilledUserUpdateThenUpdatedUser() { + String defaultPass = "pass"; + Long userId = 1L; + User partiallyFilledUser = new User( + null, + "newUsername", + null, + null, + RoleType.ROLE_ADMIN, + Collections.emptyList() + ); + User userToUpdate = new User( + 1L, + "user", + defaultPass, + "email", + RoleType.ROLE_USER, + Collections.emptyList() + ); + User expected = new User( + 1L, + "newUsername", + defaultPass, + "email", + RoleType.ROLE_ADMIN, + Collections.emptyList() + ); + + when(userRepository.findById(userId)) + .thenReturn(Optional.of(userToUpdate)); + when(userRepository.update(expected)) + .thenReturn(expected); + when(passwordEncoder.encode(defaultPass)) + .thenReturn(defaultPass); + + User actual = userService.update(userId, partiallyFilledUser); + + assertEquals(expected, actual); + verify(userRepository, times(1)) + .update(expected); + verify(userRepository, times(1)) + .findById(1L); + } + + @Test + @DisplayName("update test: with filled User data.") + void givenFilledUserAndUserIdWhenUpdateThenUpdatedUser() { + String defaultPass = "pass"; + Long userId = 1L; + User userToUpdate = new User( + 1L, + "user", + defaultPass, + "email", + RoleType.ROLE_USER, + Collections.emptyList() + ); + + when(userRepository.findById(userId)) + .thenReturn(Optional.of(userToUpdate)); + when(userRepository.update(userToUpdate)) + .thenReturn(userToUpdate); + when(passwordEncoder.encode(defaultPass)) + .thenReturn(defaultPass); + + User actual = userService.update(userId, userToUpdate); + + assertEquals(userToUpdate, actual); + verify(userRepository, times(1)) + .update(userToUpdate); + verify(userRepository, times(1)) + .findById(1L); + } + + @Test + @DisplayName("update test: try update with not existed user id.") + void givenUserAndNotExistedUserIdWhenUpdateThenUpdatedUser() { + String defaultPass = "pass"; + Long notExistedUserId = 1L; + User userToUpdate = new User( + 1L, + "user", + defaultPass, + "email", + RoleType.ROLE_USER, + Collections.emptyList() + ); + + when(userRepository.findById(notExistedUserId)) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> userService.update(notExistedUserId, userToUpdate), + "UserId is incorrect." + ); + verify(userRepository, times(0)) + .update(any()); + verify(userRepository, times(1)) + .findById(any()); + } + + @Test + @DisplayName("delete test: delete user data message to repository.") + void givenExistedUserIdWhenDeleteThenVoid() { + Long existedUserId = 1L; + + when(userRepository.findById(existedUserId)) + .thenReturn(Optional.of(new User())); + userService.deleteById(existedUserId); + + verify(userRepository, times(1)) + .findById(existedUserId); + verify(userRepository, times(1)) + .deleteById(existedUserId); + } + + @Test + @DisplayName("delete test: delete user data message to repository.") + void givenNotExistedUserIdWhenDeleteThenVoid() { + Long notExistedUserId = 1L; + + assertThrows(EntityNotFoundException.class, + () -> userService.deleteById(notExistedUserId), + "UserId is incorrect." + ); + verify(userRepository, times(1)) + .findById(notExistedUserId); + verify(userRepository, times(0)) + .deleteById(notExistedUserId); + } +} diff --git a/src/test/java/com/emobile/springtodo/service/impl/security/SecurityServiceImplTest.java b/src/test/java/com/emobile/springtodo/service/impl/security/SecurityServiceImplTest.java new file mode 100644 index 00000000..bfaafd30 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/service/impl/security/SecurityServiceImplTest.java @@ -0,0 +1,52 @@ +package com.emobile.springtodo.service.impl.security; + +import com.emobile.springtodo.model.entity.User; +import com.emobile.springtodo.model.security.RoleType; +import com.emobile.springtodo.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SecurityServiceImplTest Tests") +class SecurityServiceImplTest { + private SecurityServiceImpl securityService; + @Mock + private UserService userService; + + @BeforeEach + void setUp() { + securityService = new SecurityServiceImpl(userService); + } + + @Test + @DisplayName("registerNewUser test: send to new user data to UserService.") + void givenUserWhenRegisterNewUserThenUser() { + User user = new User( + 1L, + "user", + "pass", + "email", + RoleType.ROLE_USER, + Collections.emptyList() + ); + + when(userService.save(user)).thenReturn(user); + User actual = securityService.registerNewUser(user); + + assertEquals(user, actual); + verify(userService, times(1)) + .save(any()); + } +} diff --git a/src/test/java/com/emobile/springtodo/service/impl/security/UserDetailsServiceImplTest.java b/src/test/java/com/emobile/springtodo/service/impl/security/UserDetailsServiceImplTest.java new file mode 100644 index 00000000..c708ddf1 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/service/impl/security/UserDetailsServiceImplTest.java @@ -0,0 +1,59 @@ +package com.emobile.springtodo.service.impl.security; + +import com.emobile.springtodo.model.entity.User; +import com.emobile.springtodo.model.security.AppUserDetails; +import com.emobile.springtodo.model.security.RoleType; +import com.emobile.springtodo.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserDetailsServiceImplTest Tests") +class UserDetailsServiceImplTest { + private UserDetailsServiceImpl userDetailsService; + @Mock + private UserService userService; + + @BeforeEach + void setUp() { + userDetailsService = new UserDetailsServiceImpl(userService); + } + + @Test + @DisplayName("loadUserByUsername test: try to load " + + "user by username with correct username.") + void givenExistingUsernameWhenLoadUserByUsernameThenUserDetails() { + String userUsername = "user"; + User existingUser = new User( + 1L, + "user", + "pass", + "email", + RoleType.ROLE_USER, + Collections.emptyList() + ); + UserDetails expected = new AppUserDetails(existingUser); + + when(userService.findByUsername(userUsername)) + .thenReturn(existingUser); + + UserDetails actual = userDetailsService.loadUserByUsername(userUsername); + + assertEquals(expected, actual); + verify(userService, times(1)) + .findByUsername(any()); + } +}