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 extends GrantedAuthority> 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 extends T> 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());
+ }
+}