diff --git a/pom.xml b/pom.xml
index 53cb833..d92de1c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -32,6 +32,7 @@
1.6.3
0.2.0
checkstyle.xml
+ 0.12.6
@@ -89,6 +90,23 @@
2.3.232
test
+
+ io.jsonwebtoken
+ jjwt-api
+ ${jjwt.version}
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ ${jjwt.version}
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ ${jjwt.version}
+ runtime
+
diff --git a/src/main/java/com/senkiv/carsharing/config/SecurityConfig.java b/src/main/java/com/senkiv/carsharing/config/SecurityConfig.java
index b5e1b93..05d0a3a 100644
--- a/src/main/java/com/senkiv/carsharing/config/SecurityConfig.java
+++ b/src/main/java/com/senkiv/carsharing/config/SecurityConfig.java
@@ -1,29 +1,58 @@
package com.senkiv.carsharing.config;
+import com.senkiv.carsharing.security.JwtAuthenticationFilter;
+import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.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;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableMethodSecurity
+@RequiredArgsConstructor
public class SecurityConfig {
+ private final UserDetailsService userDetailsService;
+ private final JwtAuthenticationFilter authenticationFilter;
+
@Bean
- SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> {
- auth.requestMatchers("/cars", "/cars/**").permitAll();
+ auth.requestMatchers("/cars", "/cars/**", "/auth/**", "/register", "/login",
+ "/error",
+ "/v3/api-docs/**",
+ "/swagger-ui/**").permitAll()
+ .anyRequest().authenticated();
})
+ .userDetailsService(userDetailsService)
+ .addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class)
.httpBasic(Customizer.withDefaults())
.sessionManagement(
session -> session.sessionCreationPolicy(
SessionCreationPolicy.STATELESS))
.build();
}
+
+ @Bean
+ public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
+ throws Exception {
+ return configuration.getAuthenticationManager();
+ }
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
}
diff --git a/src/main/java/com/senkiv/carsharing/controller/AuthenticationController.java b/src/main/java/com/senkiv/carsharing/controller/AuthenticationController.java
new file mode 100644
index 0000000..41d8de0
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/controller/AuthenticationController.java
@@ -0,0 +1,30 @@
+package com.senkiv.carsharing.controller;
+
+import com.senkiv.carsharing.dto.UserLoginRequestDto;
+import com.senkiv.carsharing.dto.UserLoginResponseDto;
+import com.senkiv.carsharing.dto.UserRegistrationRequestDto;
+import com.senkiv.carsharing.dto.UserResponseDto;
+import com.senkiv.carsharing.service.AuthenticationService;
+import com.senkiv.carsharing.service.UserService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+public class AuthenticationController {
+ private final AuthenticationService authenticationService;
+ private final UserService userService;
+
+ @PostMapping("/register")
+ public UserResponseDto register(@RequestBody @Valid UserRegistrationRequestDto dto) {
+ return userService.register(dto);
+ }
+
+ @PostMapping("/login")
+ public UserLoginResponseDto authenticate(@RequestBody @Valid UserLoginRequestDto dto) {
+ return authenticationService.authenticate(dto);
+ }
+}
diff --git a/src/main/java/com/senkiv/carsharing/controller/CarController.java b/src/main/java/com/senkiv/carsharing/controller/CarController.java
new file mode 100644
index 0000000..a7c8d60
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/controller/CarController.java
@@ -0,0 +1,54 @@
+package com.senkiv.carsharing.controller;
+
+import com.senkiv.carsharing.dto.CarRequestDto;
+import com.senkiv.carsharing.dto.CarResponseDto;
+import com.senkiv.carsharing.service.CarService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.HttpStatus;
+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.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/cars")
+@RequiredArgsConstructor
+public class CarController {
+ private final CarService carService;
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public CarResponseDto createCar(@RequestBody @Valid CarRequestDto requestDto) {
+ return carService.create(requestDto);
+ }
+
+ @GetMapping
+ public Page getAllCars(Pageable pageable) {
+ return carService.getAll(pageable);
+ }
+
+ @GetMapping("/{id}")
+ public CarResponseDto getCarById(@PathVariable Long id) {
+ return carService.getById(id);
+ }
+
+ @PutMapping("/{id}")
+ public CarResponseDto updateCar(@PathVariable Long id,
+ @RequestBody @Valid CarRequestDto requestDto) {
+ return carService.update(id, requestDto);
+ }
+
+ @DeleteMapping("/{id}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void deleteCar(@PathVariable Long id) {
+ carService.delete(id);
+ }
+}
diff --git a/src/main/java/com/senkiv/carsharing/controller/GlobalExceptionHandler.java b/src/main/java/com/senkiv/carsharing/controller/GlobalExceptionHandler.java
new file mode 100644
index 0000000..3af81c8
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/controller/GlobalExceptionHandler.java
@@ -0,0 +1,17 @@
+package com.senkiv.carsharing.controller;
+
+import jakarta.persistence.EntityNotFoundException;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+@ControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(EntityNotFoundException.class)
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ public void handleEntityNotFoundException() {
+ // Just return the status code
+ }
+}
diff --git a/src/main/java/com/senkiv/carsharing/controller/UserController.java b/src/main/java/com/senkiv/carsharing/controller/UserController.java
new file mode 100644
index 0000000..7addc75
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/controller/UserController.java
@@ -0,0 +1,47 @@
+package com.senkiv.carsharing.controller;
+
+import com.senkiv.carsharing.dto.UserProfileUpdateRequestDto;
+import com.senkiv.carsharing.dto.UserResponseDto;
+import com.senkiv.carsharing.dto.UserRoleUpdateRequestDto;
+import com.senkiv.carsharing.service.UserService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+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;
+
+@RestController
+@RequestMapping("/users")
+@RequiredArgsConstructor
+public class UserController {
+ private final UserService userService;
+
+ @PutMapping("/{id}/role")
+ @PreAuthorize("hasRole('ROLE_MANAGER')")
+ public UserResponseDto updateRole(@PathVariable Long id,
+ @RequestBody @Valid UserRoleUpdateRequestDto requestDto) {
+ return userService.updateRole(id, requestDto);
+ }
+
+ @GetMapping("/me")
+ public UserResponseDto getMyProfile() {
+ return userService.getCurrentUser();
+ }
+
+ @PutMapping("/me")
+ public UserResponseDto updateProfile(
+ @RequestBody @Valid UserProfileUpdateRequestDto requestDto) {
+ return userService.updateProfile(requestDto);
+ }
+
+ @PatchMapping("/me")
+ public UserResponseDto patchProfile(
+ @RequestBody @Valid UserProfileUpdateRequestDto requestDto) {
+ return userService.updateProfile(requestDto);
+ }
+}
diff --git a/src/main/java/com/senkiv/carsharing/dto/CarRequestDto.java b/src/main/java/com/senkiv/carsharing/dto/CarRequestDto.java
new file mode 100644
index 0000000..67697a2
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/dto/CarRequestDto.java
@@ -0,0 +1,24 @@
+package com.senkiv.carsharing.dto;
+
+import com.senkiv.carsharing.model.CarType;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import java.math.BigDecimal;
+
+public record CarRequestDto(
+ @NotBlank
+ String model,
+ @NotBlank
+ String brand,
+ @NotNull
+ CarType type,
+ @NotNull
+ @Min(0)
+ Integer inventory,
+ @NotNull
+ @Positive
+ BigDecimal dailyFee
+) {
+}
diff --git a/src/main/java/com/senkiv/carsharing/dto/CarResponseDto.java b/src/main/java/com/senkiv/carsharing/dto/CarResponseDto.java
new file mode 100644
index 0000000..bc2d341
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/dto/CarResponseDto.java
@@ -0,0 +1,14 @@
+package com.senkiv.carsharing.dto;
+
+import com.senkiv.carsharing.model.CarType;
+import java.math.BigDecimal;
+
+public record CarResponseDto(
+ Long id,
+ String model,
+ String brand,
+ CarType type,
+ Integer inventory,
+ BigDecimal dailyFee
+) {
+}
diff --git a/src/main/java/com/senkiv/carsharing/dto/UserLoginRequestDto.java b/src/main/java/com/senkiv/carsharing/dto/UserLoginRequestDto.java
new file mode 100644
index 0000000..5eb8923
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/dto/UserLoginRequestDto.java
@@ -0,0 +1,14 @@
+package com.senkiv.carsharing.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record UserLoginRequestDto(
+ @Email
+ @NotNull
+ String email,
+ @NotBlank
+ String password
+) {
+}
diff --git a/src/main/java/com/senkiv/carsharing/dto/UserLoginResponseDto.java b/src/main/java/com/senkiv/carsharing/dto/UserLoginResponseDto.java
new file mode 100644
index 0000000..2a5b174
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/dto/UserLoginResponseDto.java
@@ -0,0 +1,6 @@
+package com.senkiv.carsharing.dto;
+
+public record UserLoginResponseDto(
+ String token
+) {
+}
diff --git a/src/main/java/com/senkiv/carsharing/dto/UserProfileUpdateRequestDto.java b/src/main/java/com/senkiv/carsharing/dto/UserProfileUpdateRequestDto.java
new file mode 100644
index 0000000..581b486
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/dto/UserProfileUpdateRequestDto.java
@@ -0,0 +1,16 @@
+package com.senkiv.carsharing.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record UserProfileUpdateRequestDto(
+ @NotBlank
+ String firstName,
+ @NotBlank
+ String lastName,
+ @Email
+ @NotNull
+ String email
+) {
+}
diff --git a/src/main/java/com/senkiv/carsharing/dto/UserRegistrationRequestDto.java b/src/main/java/com/senkiv/carsharing/dto/UserRegistrationRequestDto.java
new file mode 100644
index 0000000..0fa7247
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/dto/UserRegistrationRequestDto.java
@@ -0,0 +1,23 @@
+package com.senkiv.carsharing.dto;
+
+import com.senkiv.carsharing.validation.FieldMatch;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+@FieldMatch(first = "password", second = "repeatedPassword")
+public record UserRegistrationRequestDto(
+ @NotBlank
+ String firstName,
+ @NotBlank
+ String lastName,
+ @Email
+ @NotNull
+ String email,
+ @NotBlank
+ String password,
+ @NotBlank
+ String repeatedPassword
+) {
+
+}
diff --git a/src/main/java/com/senkiv/carsharing/dto/UserResponseDto.java b/src/main/java/com/senkiv/carsharing/dto/UserResponseDto.java
new file mode 100644
index 0000000..e343b08
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/dto/UserResponseDto.java
@@ -0,0 +1,13 @@
+package com.senkiv.carsharing.dto;
+
+import com.senkiv.carsharing.model.Role.RoleName;
+import java.util.Set;
+
+public record UserResponseDto(
+ Long id,
+ String firstName,
+ String lastName,
+ String email,
+ Set roles
+) {
+}
diff --git a/src/main/java/com/senkiv/carsharing/dto/UserRoleUpdateRequestDto.java b/src/main/java/com/senkiv/carsharing/dto/UserRoleUpdateRequestDto.java
new file mode 100644
index 0000000..52e294e
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/dto/UserRoleUpdateRequestDto.java
@@ -0,0 +1,10 @@
+package com.senkiv.carsharing.dto;
+
+import com.senkiv.carsharing.model.Role.RoleName;
+import jakarta.validation.constraints.NotNull;
+
+public record UserRoleUpdateRequestDto(
+ @NotNull
+ RoleName roleName
+) {
+}
diff --git a/src/main/java/com/senkiv/carsharing/exception/RegistrationException.java b/src/main/java/com/senkiv/carsharing/exception/RegistrationException.java
new file mode 100644
index 0000000..e04f71b
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/exception/RegistrationException.java
@@ -0,0 +1,7 @@
+package com.senkiv.carsharing.exception;
+
+public class RegistrationException extends RuntimeException {
+ public RegistrationException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/senkiv/carsharing/mapper/CarMapper.java b/src/main/java/com/senkiv/carsharing/mapper/CarMapper.java
new file mode 100644
index 0000000..60c7be5
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/mapper/CarMapper.java
@@ -0,0 +1,17 @@
+package com.senkiv.carsharing.mapper;
+
+import com.senkiv.carsharing.config.MapperConfig;
+import com.senkiv.carsharing.dto.CarRequestDto;
+import com.senkiv.carsharing.dto.CarResponseDto;
+import com.senkiv.carsharing.model.Car;
+import org.mapstruct.Mapper;
+import org.mapstruct.MappingTarget;
+
+@Mapper(config = MapperConfig.class)
+public interface CarMapper {
+ Car toModel(CarRequestDto dto);
+
+ CarResponseDto toDto(Car car);
+
+ Car update(CarRequestDto carRequestDto, @MappingTarget Car car);
+}
diff --git a/src/main/java/com/senkiv/carsharing/mapper/UserMapper.java b/src/main/java/com/senkiv/carsharing/mapper/UserMapper.java
new file mode 100644
index 0000000..2d6c562
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/mapper/UserMapper.java
@@ -0,0 +1,34 @@
+package com.senkiv.carsharing.mapper;
+
+import com.senkiv.carsharing.config.MapperConfig;
+import com.senkiv.carsharing.dto.UserProfileUpdateRequestDto;
+import com.senkiv.carsharing.dto.UserRegistrationRequestDto;
+import com.senkiv.carsharing.dto.UserResponseDto;
+import com.senkiv.carsharing.model.Role;
+import com.senkiv.carsharing.model.Role.RoleName;
+import com.senkiv.carsharing.model.User;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.Named;
+
+@Mapper(config = MapperConfig.class)
+public interface UserMapper {
+ User toModel(UserRegistrationRequestDto dto);
+
+ User toModel(UserProfileUpdateRequestDto dto);
+
+ @Mapping(target = "roles", source = "roles", qualifiedByName = "rolesToRoleNames")
+ UserResponseDto toDto(User user);
+
+ @Named("rolesToRoleNames")
+ default Set rolesToRoleNames(Set roles) {
+ if (roles == null) {
+ return Set.of();
+ }
+ return roles.stream()
+ .map(Role::getRoleName)
+ .collect(Collectors.toSet());
+ }
+}
diff --git a/src/main/java/com/senkiv/carsharing/model/Role.java b/src/main/java/com/senkiv/carsharing/model/Role.java
index 94b1f22..6b3cefb 100644
--- a/src/main/java/com/senkiv/carsharing/model/Role.java
+++ b/src/main/java/com/senkiv/carsharing/model/Role.java
@@ -1,6 +1,36 @@
package com.senkiv.carsharing.model;
-public enum Role {
- MANAGER,
- CUSTOMER
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.security.core.GrantedAuthority;
+
+@Entity
+@Table(name = "roles")
+@Getter
+@Setter
+public class Role implements GrantedAuthority {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+ @Enumerated(value = EnumType.STRING)
+ @Column(unique = true, nullable = false)
+ private RoleName roleName;
+
+ @Override
+ public String getAuthority() {
+ return "ROLE_" + roleName;
+ }
+
+ public enum RoleName {
+ MANAGER,
+ CUSTOMER
+ }
}
diff --git a/src/main/java/com/senkiv/carsharing/model/User.java b/src/main/java/com/senkiv/carsharing/model/User.java
index c14a726..606bcfc 100644
--- a/src/main/java/com/senkiv/carsharing/model/User.java
+++ b/src/main/java/com/senkiv/carsharing/model/User.java
@@ -2,19 +2,35 @@
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
-import jakarta.persistence.EnumType;
-import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.JoinTable;
+import jakarta.persistence.ManyToMany;
+import jakarta.persistence.NamedAttributeNode;
+import jakarta.persistence.NamedEntityGraph;
import jakarta.persistence.Table;
import java.util.Collection;
-import java.util.List;
+import java.util.HashSet;
+import java.util.Set;
+import lombok.Getter;
+import lombok.Setter;
+import org.hibernate.annotations.SQLDelete;
+import org.hibernate.annotations.SQLRestriction;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
@Entity
@Table(name = "users")
+@Getter
+@Setter
+@SQLDelete(sql = "UPDATE users SET is_deleted = true WHERE id = ?")
+@SQLRestriction("is_deleted = false")
+@NamedEntityGraph(
+ name = "user-with-roles",
+ attributeNodes = @NamedAttributeNode("roles")
+)
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -27,15 +43,19 @@ public class User implements UserDetails {
private String email;
@Column(nullable = false)
private String password;
- @Column(nullable = false)
- @Enumerated(EnumType.STRING)
- private Role role;
+ @ManyToMany
+ @JoinTable(
+ name = "users_roles",
+ joinColumns = @JoinColumn(name = "user_id"),
+ inverseJoinColumns = @JoinColumn(name = "role_id")
+ )
+ private Set roles = new HashSet<>();
@Column(nullable = false, columnDefinition = "TINYINT")
private boolean isDeleted;
@Override
public Collection extends GrantedAuthority> getAuthorities() {
- return List.of();
+ return roles;
}
@Override
diff --git a/src/main/java/com/senkiv/carsharing/repository/CarRepository.java b/src/main/java/com/senkiv/carsharing/repository/CarRepository.java
new file mode 100644
index 0000000..f2aeff3
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/repository/CarRepository.java
@@ -0,0 +1,9 @@
+package com.senkiv.carsharing.repository;
+
+import com.senkiv.carsharing.model.Car;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface CarRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/senkiv/carsharing/repository/RoleRepository.java b/src/main/java/com/senkiv/carsharing/repository/RoleRepository.java
new file mode 100644
index 0000000..ce06fb6
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/repository/RoleRepository.java
@@ -0,0 +1,12 @@
+package com.senkiv.carsharing.repository;
+
+import com.senkiv.carsharing.model.Role;
+import com.senkiv.carsharing.model.Role.RoleName;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface RoleRepository extends JpaRepository {
+ Optional findByRoleName(RoleName roleName);
+}
diff --git a/src/main/java/com/senkiv/carsharing/repository/UserRepository.java b/src/main/java/com/senkiv/carsharing/repository/UserRepository.java
new file mode 100644
index 0000000..8afc22d
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/repository/UserRepository.java
@@ -0,0 +1,15 @@
+package com.senkiv.carsharing.repository;
+
+import com.senkiv.carsharing.model.User;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface UserRepository extends JpaRepository {
+ @EntityGraph("user-with-roles")
+ Optional findByEmail(String email);
+
+ boolean existsByEmail(String email);
+}
diff --git a/src/main/java/com/senkiv/carsharing/security/JwtAuthenticationFilter.java b/src/main/java/com/senkiv/carsharing/security/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..ea5747c
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/security/JwtAuthenticationFilter.java
@@ -0,0 +1,53 @@
+package com.senkiv.carsharing.security;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+@Component
+@RequiredArgsConstructor
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+ private final JwtUtil jwtUtil;
+ private final UserDetailsService userDetailService;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ getToken(request).ifPresent(jwt -> {
+ if (jwtUtil.isValid(jwt)) {
+ createAuthenticationFromToken(jwt);
+ }
+ });
+ filterChain.doFilter(request, response);
+ }
+
+ private Optional getToken(HttpServletRequest request) {
+ String header = request.getHeader(HttpHeaders.AUTHORIZATION);
+ if (StringUtils.hasText(header) && header.startsWith("Bearer")) {
+ String[] tokenPartsArr = header.split(" ", 2);
+ return Optional.of(tokenPartsArr[1]);
+ }
+ return Optional.empty();
+ }
+
+ private void createAuthenticationFromToken(String token) {
+ String username = jwtUtil.getUsername(token);
+ UserDetails userDetails = userDetailService.loadUserByUsername(username);
+ Authentication authentication = new UsernamePasswordAuthenticationToken(
+ userDetails, null, userDetails.getAuthorities());
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+}
diff --git a/src/main/java/com/senkiv/carsharing/security/JwtUtil.java b/src/main/java/com/senkiv/carsharing/security/JwtUtil.java
new file mode 100644
index 0000000..70b2d33
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/security/JwtUtil.java
@@ -0,0 +1,54 @@
+package com.senkiv.carsharing.security;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jws;
+import io.jsonwebtoken.JwtParser;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import jakarta.annotation.PostConstruct;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import javax.crypto.SecretKey;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+@Component
+public class JwtUtil {
+ @Value("${token.validity}")
+ private Long validityTime;
+ @Value("${token.secret}")
+ private String secret;
+ private SecretKey secretKey;
+
+ public String generateToken(String username) {
+ return Jwts.builder()
+ .subject(username)
+ .issuedAt(new Date(System.currentTimeMillis()))
+ .expiration(new Date(System.currentTimeMillis() + validityTime))
+ .signWith(secretKey)
+ .compact();
+ }
+
+ public String getUsername(String token) {
+ JwtParser parser = Jwts.parser()
+ .verifyWith(secretKey)
+ .build();
+ Jws claims = parser.parseSignedClaims(token);
+ return claims.getPayload().getSubject();
+ }
+
+ public boolean isValid(String token) {
+ try {
+ JwtParser parser = Jwts.parser().verifyWith(secretKey).build();
+ Date expiration = parser.parseSignedClaims(token).getPayload().getExpiration();
+ return expiration.after(new Date(System.currentTimeMillis()));
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ @PostConstruct
+ private void init() {
+ secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/src/main/java/com/senkiv/carsharing/security/UserDetailServiceImpl.java b/src/main/java/com/senkiv/carsharing/security/UserDetailServiceImpl.java
new file mode 100644
index 0000000..96f0b58
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/security/UserDetailServiceImpl.java
@@ -0,0 +1,21 @@
+package com.senkiv.carsharing.security;
+
+import com.senkiv.carsharing.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class UserDetailServiceImpl implements UserDetailsService {
+ public static final String USER_NOT_FOUND_MESSAGE = "User with email %s does not exist";
+ private final UserRepository userRepository;
+
+ @Override
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ return userRepository.findByEmail(username).orElseThrow(() -> new UsernameNotFoundException(
+ USER_NOT_FOUND_MESSAGE.formatted(username)));
+ }
+}
diff --git a/src/main/java/com/senkiv/carsharing/service/AuthenticationService.java b/src/main/java/com/senkiv/carsharing/service/AuthenticationService.java
new file mode 100644
index 0000000..fc76fbc
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/service/AuthenticationService.java
@@ -0,0 +1,9 @@
+package com.senkiv.carsharing.service;
+
+import com.senkiv.carsharing.dto.UserLoginRequestDto;
+import com.senkiv.carsharing.dto.UserLoginResponseDto;
+
+public interface AuthenticationService {
+
+ UserLoginResponseDto authenticate(UserLoginRequestDto dto);
+}
diff --git a/src/main/java/com/senkiv/carsharing/service/CarService.java b/src/main/java/com/senkiv/carsharing/service/CarService.java
new file mode 100644
index 0000000..0ae63a1
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/service/CarService.java
@@ -0,0 +1,18 @@
+package com.senkiv.carsharing.service;
+
+import com.senkiv.carsharing.dto.CarRequestDto;
+import com.senkiv.carsharing.dto.CarResponseDto;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+public interface CarService {
+ CarResponseDto create(CarRequestDto requestDto);
+
+ Page getAll(Pageable pageable);
+
+ CarResponseDto getById(Long id);
+
+ CarResponseDto update(Long id, CarRequestDto requestDto);
+
+ void delete(Long id);
+}
diff --git a/src/main/java/com/senkiv/carsharing/service/UserService.java b/src/main/java/com/senkiv/carsharing/service/UserService.java
new file mode 100644
index 0000000..adecda8
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/service/UserService.java
@@ -0,0 +1,16 @@
+package com.senkiv.carsharing.service;
+
+import com.senkiv.carsharing.dto.UserProfileUpdateRequestDto;
+import com.senkiv.carsharing.dto.UserRegistrationRequestDto;
+import com.senkiv.carsharing.dto.UserResponseDto;
+import com.senkiv.carsharing.dto.UserRoleUpdateRequestDto;
+
+public interface UserService {
+ UserResponseDto register(UserRegistrationRequestDto dto);
+
+ UserResponseDto updateRole(Long userId, UserRoleUpdateRequestDto dto);
+
+ UserResponseDto getCurrentUser();
+
+ UserResponseDto updateProfile(UserProfileUpdateRequestDto dto);
+}
diff --git a/src/main/java/com/senkiv/carsharing/service/impl/AuthenticationServiceImpl.java b/src/main/java/com/senkiv/carsharing/service/impl/AuthenticationServiceImpl.java
new file mode 100644
index 0000000..d6c5eb0
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/service/impl/AuthenticationServiceImpl.java
@@ -0,0 +1,31 @@
+package com.senkiv.carsharing.service.impl;
+
+import com.senkiv.carsharing.dto.UserLoginRequestDto;
+import com.senkiv.carsharing.dto.UserLoginResponseDto;
+import com.senkiv.carsharing.security.JwtUtil;
+import com.senkiv.carsharing.service.AuthenticationService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class AuthenticationServiceImpl implements AuthenticationService {
+
+ private final AuthenticationManager authenticationManager;
+ private final JwtUtil jwtUtil;
+
+ @Override
+ public UserLoginResponseDto authenticate(UserLoginRequestDto dto) {
+ final Authentication authentication = authenticationManager.authenticate(
+ new UsernamePasswordAuthenticationToken(
+ dto.email(),
+ dto.password()
+ )
+ );
+ String token = jwtUtil.generateToken(authentication.getName());
+ return new UserLoginResponseDto(token);
+ }
+}
diff --git a/src/main/java/com/senkiv/carsharing/service/impl/CarServiceImpl.java b/src/main/java/com/senkiv/carsharing/service/impl/CarServiceImpl.java
new file mode 100644
index 0000000..6fa9ebe
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/service/impl/CarServiceImpl.java
@@ -0,0 +1,66 @@
+package com.senkiv.carsharing.service.impl;
+
+import com.senkiv.carsharing.dto.CarRequestDto;
+import com.senkiv.carsharing.dto.CarResponseDto;
+import com.senkiv.carsharing.mapper.CarMapper;
+import com.senkiv.carsharing.model.Car;
+import com.senkiv.carsharing.repository.CarRepository;
+import com.senkiv.carsharing.service.CarService;
+import jakarta.persistence.EntityNotFoundException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class CarServiceImpl implements CarService {
+ private static final String CAR_NOT_FOUND_MESSAGE = "Car with id %d not found";
+
+ private final CarRepository carRepository;
+ private final CarMapper carMapper;
+
+ @Override
+ @Transactional
+ public CarResponseDto create(CarRequestDto requestDto) {
+ Car car = carMapper.toModel(requestDto);
+ return carMapper.toDto(carRepository.save(car));
+ }
+
+ @Override
+ public Page getAll(Pageable pageable) {
+ return carRepository.findAll(pageable)
+ .map(carMapper::toDto);
+
+ }
+
+ @Override
+ public CarResponseDto getById(Long id) {
+ Car car = carRepository.findById(id)
+ .orElseThrow(() -> new EntityNotFoundException(
+ CAR_NOT_FOUND_MESSAGE.formatted(id)));
+ return carMapper.toDto(car);
+ }
+
+ @Override
+ @Transactional
+ public CarResponseDto update(Long id, CarRequestDto requestDto) {
+ Car car = carRepository.findById(id)
+ .orElseThrow(() -> new EntityNotFoundException(
+ CAR_NOT_FOUND_MESSAGE.formatted(id)));
+
+ carMapper.update(requestDto, car);
+
+ return carMapper.toDto(carRepository.save(car));
+ }
+
+ @Override
+ @Transactional
+ public void delete(Long id) {
+ carRepository.findById(id)
+ .orElseThrow(() -> new EntityNotFoundException(
+ CAR_NOT_FOUND_MESSAGE.formatted(id)));
+ carRepository.deleteById(id);
+ }
+}
diff --git a/src/main/java/com/senkiv/carsharing/service/impl/UserServiceImpl.java b/src/main/java/com/senkiv/carsharing/service/impl/UserServiceImpl.java
new file mode 100644
index 0000000..623bbbf
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/service/impl/UserServiceImpl.java
@@ -0,0 +1,105 @@
+package com.senkiv.carsharing.service.impl;
+
+import com.senkiv.carsharing.dto.UserProfileUpdateRequestDto;
+import com.senkiv.carsharing.dto.UserRegistrationRequestDto;
+import com.senkiv.carsharing.dto.UserResponseDto;
+import com.senkiv.carsharing.dto.UserRoleUpdateRequestDto;
+import com.senkiv.carsharing.exception.RegistrationException;
+import com.senkiv.carsharing.mapper.UserMapper;
+import com.senkiv.carsharing.model.Role;
+import com.senkiv.carsharing.model.Role.RoleName;
+import com.senkiv.carsharing.model.User;
+import com.senkiv.carsharing.repository.RoleRepository;
+import com.senkiv.carsharing.repository.UserRepository;
+import com.senkiv.carsharing.service.UserService;
+import jakarta.persistence.EntityNotFoundException;
+import jakarta.transaction.Transactional;
+import java.util.Set;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class UserServiceImpl implements UserService {
+ public static final String EMAIL_ALREADY_USED_MESSAGE = "Email %s is already used.";
+ public static final String ERROR_DEFAULT_ROLE_ASSIGNMENT =
+ "Cannot assign default role to user.";
+ public static final String USER_NOT_FOUND_MESSAGE = "User with id %d not found";
+ public static final String ROLE_NOT_FOUND_MESSAGE = "Role %s not found";
+ private final UserRepository userRepository;
+ private final PasswordEncoder passwordEncoder;
+ private final UserMapper userMapper;
+ private final RoleRepository roleRepository;
+
+ @Override
+ @Transactional
+ public UserResponseDto register(UserRegistrationRequestDto dto) {
+ User user = userMapper.toModel(dto);
+ if (userRepository.existsByEmail(user.getUsername())) {
+ throw new RegistrationException(
+ EMAIL_ALREADY_USED_MESSAGE.formatted(user.getUsername()));
+ }
+ user.setPassword(passwordEncoder.encode(user.getPassword()));
+ user.setRoles(Set.of(roleRepository.findByRoleName(RoleName.CUSTOMER)
+ .orElseThrow(() -> new EntityNotFoundException(
+ ERROR_DEFAULT_ROLE_ASSIGNMENT))));
+ return userMapper.toDto(userRepository.save(user));
+ }
+
+ @Override
+ @Transactional
+ @PreAuthorize("hasRole('ROLE_MANAGER')")
+ public UserResponseDto updateRole(Long userId, UserRoleUpdateRequestDto dto) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new EntityNotFoundException(
+ USER_NOT_FOUND_MESSAGE.formatted(userId)));
+
+ Role role = roleRepository.findByRoleName(dto.roleName())
+ .orElseThrow(() -> new EntityNotFoundException(
+ ROLE_NOT_FOUND_MESSAGE.formatted(dto.roleName())));
+
+ user.setRoles(Set.of(role));
+ return userMapper.toDto(userRepository.save(user));
+ }
+
+ @Override
+ public UserResponseDto getCurrentUser() {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication == null || !authentication.isAuthenticated()) {
+ throw new AccessDeniedException("User not authenticated");
+ }
+
+ User user = userRepository.findByEmail(authentication.getName())
+ .orElseThrow(() -> new EntityNotFoundException("Current user not found"));
+
+ return userMapper.toDto(user);
+ }
+
+ @Override
+ @Transactional
+ public UserResponseDto updateProfile(UserProfileUpdateRequestDto dto) {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication == null || !authentication.isAuthenticated()) {
+ throw new AccessDeniedException("User not authenticated");
+ }
+
+ User user = userRepository.findByEmail(authentication.getName())
+ .orElseThrow(() -> new EntityNotFoundException("Current user not found"));
+
+ // Check if email is being changed and if it's already in use
+ if (!user.getEmail().equals(dto.email()) && userRepository.existsByEmail(dto.email())) {
+ throw new RegistrationException(EMAIL_ALREADY_USED_MESSAGE.formatted(dto.email()));
+ }
+
+ user.setFirstName(dto.firstName());
+ user.setLastName(dto.lastName());
+ user.setEmail(dto.email());
+
+ return userMapper.toDto(userRepository.save(user));
+ }
+}
diff --git a/src/main/java/com/senkiv/carsharing/validation/FieldMatch.java b/src/main/java/com/senkiv/carsharing/validation/FieldMatch.java
new file mode 100644
index 0000000..ec63429
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/validation/FieldMatch.java
@@ -0,0 +1,25 @@
+package com.senkiv.carsharing.validation;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+@Constraint(validatedBy = FieldMatchValidator.class)
+public @interface FieldMatch {
+ String FIELDS_ARE_NOT_MATCHING = "Required field are not matching";
+
+ String message() default FIELDS_ARE_NOT_MATCHING;
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+
+ String first();
+
+ String second();
+}
diff --git a/src/main/java/com/senkiv/carsharing/validation/FieldMatchValidator.java b/src/main/java/com/senkiv/carsharing/validation/FieldMatchValidator.java
new file mode 100644
index 0000000..56bc1b1
--- /dev/null
+++ b/src/main/java/com/senkiv/carsharing/validation/FieldMatchValidator.java
@@ -0,0 +1,27 @@
+package com.senkiv.carsharing.validation;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import java.util.Objects;
+import org.springframework.beans.BeanWrapper;
+import org.springframework.beans.BeanWrapperImpl;
+
+public class FieldMatchValidator implements ConstraintValidator {
+ private String firstFieldName;
+ private String secondFieldName;
+
+ @Override
+ public void initialize(FieldMatch constraintAnnotation) {
+ this.firstFieldName = constraintAnnotation.first();
+ this.secondFieldName = constraintAnnotation.second();
+ }
+
+ @Override
+ public boolean isValid(Object value, ConstraintValidatorContext context) {
+ BeanWrapper wrapper = new BeanWrapperImpl(value);
+ Object firstValue = wrapper.getPropertyValue(firstFieldName);
+ Object secondValue = wrapper.getPropertyValue(secondFieldName);
+ return Objects.equals(firstValue, secondValue);
+
+ }
+}
diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties
index 0d526f9..6f64b2c 100644
--- a/src/main/resources/application-dev.properties
+++ b/src/main/resources/application-dev.properties
@@ -5,3 +5,5 @@ spring.datasource.password=Senkiv1905!
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true
+token.validity=360000
+token.secret=123123131231_very_secret_and_confidential_token_123123123123
diff --git a/src/main/resources/db/changelog/changes/02-create-users-table.yaml b/src/main/resources/db/changelog/changes/02-create-users-table.yaml
index 3a24748..7c052d2 100644
--- a/src/main/resources/db/changelog/changes/02-create-users-table.yaml
+++ b/src/main/resources/db/changelog/changes/02-create-users-table.yaml
@@ -34,11 +34,6 @@ databaseChangeLog:
nullable: false
name: password
type: VARCHAR(255)
- - column:
- constraints:
- nullable: false
- name: role
- type: VARCHAR(255)
- column:
constraints:
nullable: false
diff --git a/src/main/resources/db/changelog/changes/05-create-roles-table.yaml b/src/main/resources/db/changelog/changes/05-create-roles-table.yaml
new file mode 100644
index 0000000..880e4ea
--- /dev/null
+++ b/src/main/resources/db/changelog/changes/05-create-roles-table.yaml
@@ -0,0 +1,20 @@
+databaseChangeLog:
+ - changeSet:
+ id: 5
+ author: Serhii Senkiv
+ changes:
+ - createTable:
+ tableName: roles
+ columns:
+ - column:
+ name: id
+ type: BIGINT
+ autoIncrement: true
+ constraints:
+ primaryKey: true
+ nullable: false
+ - column:
+ name: role_name
+ type: VARCHAR(255)
+ constraints:
+ nullable: false
diff --git a/src/main/resources/db/changelog/changes/06-create-users-roles-table.yaml b/src/main/resources/db/changelog/changes/06-create-users-roles-table.yaml
new file mode 100644
index 0000000..cdc509b
--- /dev/null
+++ b/src/main/resources/db/changelog/changes/06-create-users-roles-table.yaml
@@ -0,0 +1,24 @@
+databaseChangeLog:
+ - changeSet:
+ id: 6
+ author: Serhii Senkiv
+ changes:
+ - createTable:
+ tableName: users_roles
+ columns:
+ - column:
+ name: user_id
+ type: BIGINT
+ constraints:
+ foreignKeyName: FK_roles_user_id
+ referencedTableName: users
+ referencedColumnNames: id
+ nullable: false
+ - column:
+ name: role_id
+ type: BIGINT
+ constraints:
+ foreignKeyName: FK_users_roles_id
+ referencedTableName: roles
+ referencedColumnNames: id
+ nullable: false
\ No newline at end of file
diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml
index e9474ac..589d824 100644
--- a/src/main/resources/db/changelog/db.changelog-master.yaml
+++ b/src/main/resources/db/changelog/db.changelog-master.yaml
@@ -6,4 +6,8 @@ databaseChangeLog:
- include:
file: db/changelog/changes/03-create-rentals-table.yaml
- include:
- file: db/changelog/changes/04-create-payments-table.yaml
\ No newline at end of file
+ file: db/changelog/changes/04-create-payments-table.yaml
+ - include:
+ file: db/changelog/changes/05-create-roles-table.yaml
+ - include:
+ file: db/changelog/changes/06-create-users-roles-table.yaml
\ No newline at end of file
diff --git a/src/test/java/com/senkiv/carsharing/controller/AuthenticationControllerTest.java b/src/test/java/com/senkiv/carsharing/controller/AuthenticationControllerTest.java
new file mode 100644
index 0000000..eb24b0d
--- /dev/null
+++ b/src/test/java/com/senkiv/carsharing/controller/AuthenticationControllerTest.java
@@ -0,0 +1,135 @@
+package com.senkiv.carsharing.controller;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+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;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.senkiv.carsharing.dto.UserLoginRequestDto;
+import com.senkiv.carsharing.dto.UserLoginResponseDto;
+import com.senkiv.carsharing.dto.UserRegistrationRequestDto;
+import com.senkiv.carsharing.dto.UserResponseDto;
+import com.senkiv.carsharing.model.Role;
+import com.senkiv.carsharing.service.AuthenticationService;
+import com.senkiv.carsharing.service.UserService;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+@ExtendWith(MockitoExtension.class)
+class AuthenticationControllerTest {
+
+ private static final String TEST_EMAIL = "test@example.com";
+ private static final String TEST_PASSWORD = "password123";
+ private static final String TEST_TOKEN = "test.jwt.token";
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private MockMvc mockMvc;
+ @Mock
+ private AuthenticationService authenticationService;
+ @Mock
+ private UserService userService;
+ @InjectMocks
+ private AuthenticationController authenticationController;
+
+ @BeforeEach
+ void setUp() {
+ mockMvc = MockMvcBuilders.standaloneSetup(authenticationController)
+ .build();
+ }
+
+ @Test
+ void register_ShouldReturnUserResponseDto() throws Exception {
+ // Given
+ UserRegistrationRequestDto requestDto = new UserRegistrationRequestDto(
+ "John",
+ "Doe",
+ TEST_EMAIL,
+ TEST_PASSWORD,
+ TEST_PASSWORD
+ );
+
+ UserResponseDto responseDto = new UserResponseDto(
+ 1L,
+ "John",
+ "Doe",
+ TEST_EMAIL,
+ Set.of(Role.RoleName.CUSTOMER)
+ );
+
+ when(userService.register(any(UserRegistrationRequestDto.class))).thenReturn(responseDto);
+
+ // When & Then
+ mockMvc.perform(post("/register")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(requestDto)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(1))
+ .andExpect(jsonPath("$.firstName").value("John"))
+ .andExpect(jsonPath("$.lastName").value("Doe"))
+ .andExpect(jsonPath("$.email").value(TEST_EMAIL))
+ .andExpect(jsonPath("$.roles[0]").value("CUSTOMER"));
+ }
+
+ @Test
+ void authenticate_ShouldReturnUserLoginResponseDto() throws Exception {
+ // Given
+ UserLoginRequestDto requestDto = new UserLoginRequestDto(
+ TEST_EMAIL,
+ TEST_PASSWORD
+ );
+
+ UserLoginResponseDto responseDto = new UserLoginResponseDto(TEST_TOKEN);
+
+ when(authenticationService.authenticate(any(UserLoginRequestDto.class))).thenReturn(
+ responseDto);
+
+ // When & Then
+ mockMvc.perform(post("/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(requestDto)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.token").value(TEST_TOKEN));
+ }
+
+ @Test
+ void register_WithInvalidRequest_ShouldReturnBadRequest() throws Exception {
+ // Given
+ UserRegistrationRequestDto requestDto = new UserRegistrationRequestDto(
+ "", // Empty firstName (invalid)
+ "Doe",
+ TEST_EMAIL,
+ TEST_PASSWORD,
+ TEST_PASSWORD
+ );
+
+ // When & Then
+ mockMvc.perform(post("/register")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(requestDto)))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void authenticate_WithInvalidRequest_ShouldReturnBadRequest() throws Exception {
+ // Given
+ UserLoginRequestDto requestDto = new UserLoginRequestDto(
+ "invalid-email", // Invalid email format
+ TEST_PASSWORD
+ );
+
+ // When & Then
+ mockMvc.perform(post("/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(requestDto)))
+ .andExpect(status().isBadRequest());
+ }
+}
diff --git a/src/test/java/com/senkiv/carsharing/controller/CarControllerTest.java b/src/test/java/com/senkiv/carsharing/controller/CarControllerTest.java
new file mode 100644
index 0000000..28e8754
--- /dev/null
+++ b/src/test/java/com/senkiv/carsharing/controller/CarControllerTest.java
@@ -0,0 +1,269 @@
+package com.senkiv.carsharing.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.senkiv.carsharing.dto.CarRequestDto;
+import com.senkiv.carsharing.dto.CarResponseDto;
+import com.senkiv.carsharing.model.CarType;
+import com.senkiv.carsharing.service.CarService;
+import jakarta.persistence.EntityNotFoundException;
+import java.math.BigDecimal;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+@ExtendWith(MockitoExtension.class)
+class CarControllerTest {
+
+ private MockMvc mockMvc;
+
+ @Mock
+ private CarService carService;
+
+ @InjectMocks
+ private CarController carController;
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @BeforeEach
+ void setUp() {
+ mockMvc = MockMvcBuilders.standaloneSetup(carController)
+ .setControllerAdvice(new GlobalExceptionHandler())
+ .build();
+ }
+
+ @Test
+ void createCar_ShouldReturnCreatedCarResponseDto() throws Exception {
+ // Given
+ CarRequestDto requestDto = new CarRequestDto(
+ "Model 3",
+ "Tesla",
+ CarType.SEDAN,
+ 5,
+ BigDecimal.valueOf(100.00)
+ );
+
+ CarResponseDto responseDto = new CarResponseDto(
+ 1L,
+ "Model 3",
+ "Tesla",
+ CarType.SEDAN,
+ 5,
+ BigDecimal.valueOf(100.00)
+ );
+
+ when(carService.create(any(CarRequestDto.class))).thenReturn(responseDto);
+
+ // When & Then
+ mockMvc.perform(post("/cars")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(requestDto)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(1))
+ .andExpect(jsonPath("$.model").value("Model 3"))
+ .andExpect(jsonPath("$.brand").value("Tesla"))
+ .andExpect(jsonPath("$.type").value("SEDAN"))
+ .andExpect(jsonPath("$.inventory").value(5))
+ .andExpect(jsonPath("$.dailyFee").value(100.00));
+ }
+
+ @Test
+ void getAllCars_ShouldReturnPageOfCarResponseDto() throws Exception {
+ // Given
+ List cars = List.of(
+ new CarResponseDto(
+ 1L,
+ "Model 3",
+ "Tesla",
+ CarType.SEDAN,
+ 5,
+ BigDecimal.valueOf(100.00)
+ ),
+ new CarResponseDto(
+ 2L,
+ "Model Y",
+ "Tesla",
+ CarType.SUV,
+ 3,
+ BigDecimal.valueOf(120.00)
+ )
+ );
+
+ // Mock the controller directly instead of using MockMvc for this test
+ // This avoids the Pageable parameter resolution issue
+ when(carService.getAll(any())).thenReturn(new PageImpl<>(cars));
+
+ Page result = carController.getAllCars(null);
+
+ // Verify the result
+ assertEquals(2, result.getTotalElements());
+ assertEquals("Model 3", result.getContent().get(0).model());
+ assertEquals("Model Y", result.getContent().get(1).model());
+ }
+
+ @Test
+ void getCarById_WithExistingId_ShouldReturnCarResponseDto() throws Exception {
+ // Given
+ Long id = 1L;
+ CarResponseDto responseDto = new CarResponseDto(
+ id,
+ "Model 3",
+ "Tesla",
+ CarType.SEDAN,
+ 5,
+ BigDecimal.valueOf(100.00)
+ );
+
+ when(carService.getById(id)).thenReturn(responseDto);
+
+ // When & Then
+ mockMvc.perform(get("/cars/{id}", id))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(1))
+ .andExpect(jsonPath("$.model").value("Model 3"))
+ .andExpect(jsonPath("$.brand").value("Tesla"))
+ .andExpect(jsonPath("$.type").value("SEDAN"))
+ .andExpect(jsonPath("$.inventory").value(5))
+ .andExpect(jsonPath("$.dailyFee").value(100.00));
+ }
+
+ @Test
+ void getCarById_WithNonExistingId_ShouldReturnNotFound() throws Exception {
+ // Given
+ Long id = 999L;
+ when(carService.getById(id)).thenThrow(
+ new EntityNotFoundException("Car with id 999 not found"));
+
+ // When & Then
+ mockMvc.perform(get("/cars/{id}", id))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ void updateCar_WithExistingId_ShouldReturnUpdatedCarResponseDto() throws Exception {
+ // Given
+ Long id = 1L;
+ CarRequestDto requestDto = new CarRequestDto(
+ "Updated Model",
+ "Updated Brand",
+ CarType.SUV,
+ 10,
+ BigDecimal.valueOf(150.00)
+ );
+
+ CarResponseDto responseDto = new CarResponseDto(
+ id,
+ "Updated Model",
+ "Updated Brand",
+ CarType.SUV,
+ 10,
+ BigDecimal.valueOf(150.00)
+ );
+
+ when(carService.update(eq(id), any(CarRequestDto.class))).thenReturn(responseDto);
+
+ // When & Then
+ mockMvc.perform(put("/cars/{id}", id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(requestDto)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(1))
+ .andExpect(jsonPath("$.model").value("Updated Model"))
+ .andExpect(jsonPath("$.brand").value("Updated Brand"))
+ .andExpect(jsonPath("$.type").value("SUV"))
+ .andExpect(jsonPath("$.inventory").value(10))
+ .andExpect(jsonPath("$.dailyFee").value(150.00));
+ }
+
+ @Test
+ void updateCar_WithNonExistingId_ShouldReturnNotFound() throws Exception {
+ // Given
+ Long id = 999L;
+ CarRequestDto requestDto = new CarRequestDto(
+ "Updated Model",
+ "Updated Brand",
+ CarType.SUV,
+ 10,
+ BigDecimal.valueOf(150.00)
+ );
+
+ when(carService.update(eq(id), any(CarRequestDto.class)))
+ .thenThrow(new EntityNotFoundException("Car with id 999 not found"));
+
+ // When & Then
+ mockMvc.perform(put("/cars/{id}", id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(requestDto)))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ void deleteCar_WithExistingId_ShouldReturnNoContent() throws Exception {
+ // Given
+ Long id = 1L;
+ doNothing().when(carService).delete(id);
+
+ // When & Then
+ mockMvc.perform(delete("/cars/{id}", id))
+ .andExpect(status().isNoContent());
+
+ verify(carService).delete(id);
+ }
+
+ @Test
+ void deleteCar_WithNonExistingId_ShouldReturnNotFound() throws Exception {
+ // Given
+ Long id = 999L;
+ doThrow(new EntityNotFoundException("Car with id 999 not found"))
+ .when(carService).delete(id);
+
+ // When & Then
+ mockMvc.perform(delete("/cars/{id}", id))
+ .andExpect(status().isNotFound());
+
+ verify(carService).delete(id);
+ }
+
+ @Test
+ void createCar_WithInvalidRequest_ShouldReturnBadRequest() throws Exception {
+ // Given
+ CarRequestDto requestDto = new CarRequestDto(
+ "",// Empty model (invalid)
+ "Tesla",
+ CarType.SEDAN,
+ 5,
+ BigDecimal.valueOf(100.00)
+ );
+
+ // When & Then
+ mockMvc.perform(post("/cars")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(requestDto)))
+ .andExpect(status().isBadRequest());
+
+ verifyNoInteractions(carService);
+ }
+}
diff --git a/src/test/java/com/senkiv/carsharing/mapper/CarMapperTest.java b/src/test/java/com/senkiv/carsharing/mapper/CarMapperTest.java
new file mode 100644
index 0000000..509a8b0
--- /dev/null
+++ b/src/test/java/com/senkiv/carsharing/mapper/CarMapperTest.java
@@ -0,0 +1,58 @@
+package com.senkiv.carsharing.mapper;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import com.senkiv.carsharing.dto.CarRequestDto;
+import com.senkiv.carsharing.dto.CarResponseDto;
+import com.senkiv.carsharing.model.Car;
+import com.senkiv.carsharing.model.CarType;
+import java.math.BigDecimal;
+import org.junit.jupiter.api.Test;
+import org.mapstruct.factory.Mappers;
+
+class CarMapperTest {
+
+ private final CarMapper carMapper = Mappers.getMapper(CarMapper.class);
+
+ @Test
+ void toModel_ShouldMapDtoToEntity() {
+ CarRequestDto requestDto = new CarRequestDto(
+ "Model 3",
+ "Tesla",
+ CarType.SEDAN,
+ 5,
+ BigDecimal.valueOf(100.00)
+ );
+
+ Car car = carMapper.toModel(requestDto);
+
+ assertNotNull(car);
+ assertEquals(requestDto.model(), car.getModel());
+ assertEquals(requestDto.brand(), car.getBrand());
+ assertEquals(requestDto.type(), car.getType());
+ assertEquals(requestDto.inventory(), car.getInventory());
+ assertEquals(requestDto.dailyFee(), car.getDailyFee());
+ }
+
+ @Test
+ void toDto_ShouldMapEntityToDto() {
+ Car car = new Car();
+ car.setId(1L);
+ car.setModel("Model 3");
+ car.setBrand("Tesla");
+ car.setType(CarType.SEDAN);
+ car.setInventory(5);
+ car.setDailyFee(BigDecimal.valueOf(100.00));
+
+ CarResponseDto responseDto = carMapper.toDto(car);
+
+ assertNotNull(responseDto);
+ assertEquals(car.getId(), responseDto.id());
+ assertEquals(car.getModel(), responseDto.model());
+ assertEquals(car.getBrand(), responseDto.brand());
+ assertEquals(car.getType(), responseDto.type());
+ assertEquals(car.getInventory(), responseDto.inventory());
+ assertEquals(car.getDailyFee(), responseDto.dailyFee());
+ }
+}
diff --git a/src/test/java/com/senkiv/carsharing/security/JwtAuthenticationFilterTest.java b/src/test/java/com/senkiv/carsharing/security/JwtAuthenticationFilterTest.java
new file mode 100644
index 0000000..a5990a8
--- /dev/null
+++ b/src/test/java/com/senkiv/carsharing/security/JwtAuthenticationFilterTest.java
@@ -0,0 +1,113 @@
+package com.senkiv.carsharing.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Collections;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+
+@ExtendWith(MockitoExtension.class)
+class JwtAuthenticationFilterTest {
+
+ private static final String TEST_TOKEN = "test.jwt.token";
+ private static final String TEST_USERNAME = "test@example.com";
+ private static final String AUTHORIZATION_HEADER = "Bearer " + TEST_TOKEN;
+
+ @Mock
+ private JwtUtil jwtUtil;
+
+ @Mock
+ private UserDetailsService userDetailsService;
+
+ @Mock
+ private HttpServletRequest request;
+
+ @Mock
+ private HttpServletResponse response;
+
+ @Mock
+ private FilterChain filterChain;
+
+ @Mock
+ private UserDetails userDetails;
+
+ @InjectMocks
+ private JwtAuthenticationFilter jwtAuthenticationFilter;
+
+ @BeforeEach
+ void setUp() {
+ SecurityContextHolder.clearContext();
+ }
+
+ @Test
+ void doFilterInternal_WithValidToken_ShouldSetAuthentication()
+ throws ServletException, IOException {
+ when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn(AUTHORIZATION_HEADER);
+ when(jwtUtil.isValid(TEST_TOKEN)).thenReturn(true);
+ when(jwtUtil.getUsername(TEST_TOKEN)).thenReturn(TEST_USERNAME);
+ when(userDetailsService.loadUserByUsername(TEST_USERNAME)).thenReturn(userDetails);
+ when(userDetails.getAuthorities()).thenReturn(Collections.emptyList());
+
+ jwtAuthenticationFilter.doFilterInternal(request, response, filterChain);
+
+ verify(filterChain).doFilter(request, response);
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ assertNotNull(authentication);
+ assertEquals(userDetails, authentication.getPrincipal());
+ }
+
+ @Test
+ void doFilterInternal_WithInvalidToken_ShouldNotSetAuthentication()
+ throws ServletException, IOException {
+ when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn(AUTHORIZATION_HEADER);
+ when(jwtUtil.isValid(TEST_TOKEN)).thenReturn(false);
+
+ jwtAuthenticationFilter.doFilterInternal(request, response, filterChain);
+
+ verify(filterChain).doFilter(request, response);
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ assertNull(authentication);
+ }
+
+ @Test
+ void doFilterInternal_WithNoToken_ShouldNotSetAuthentication()
+ throws ServletException, IOException {
+ when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn(null);
+
+ jwtAuthenticationFilter.doFilterInternal(request, response, filterChain);
+
+ verify(filterChain).doFilter(request, response);
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ assertNull(authentication);
+ }
+
+ @Test
+ void doFilterInternal_WithMalformedToken_ShouldNotSetAuthentication()
+ throws ServletException, IOException {
+ when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn("InvalidTokenFormat");
+
+ jwtAuthenticationFilter.doFilterInternal(request, response, filterChain);
+
+ verify(filterChain).doFilter(request, response);
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ assertNull(authentication);
+ }
+}
diff --git a/src/test/java/com/senkiv/carsharing/security/JwtUtilTest.java b/src/test/java/com/senkiv/carsharing/security/JwtUtilTest.java
new file mode 100644
index 0000000..42e18e6
--- /dev/null
+++ b/src/test/java/com/senkiv/carsharing/security/JwtUtilTest.java
@@ -0,0 +1,83 @@
+package com.senkiv.carsharing.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jws;
+import io.jsonwebtoken.JwtParser;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import java.nio.charset.StandardCharsets;
+import javax.crypto.SecretKey;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+class JwtUtilTest {
+ private static final String SECRET = "testSecretKeyForJwtTokenGenerationAndValidation";
+ private static final long VALIDITY_TIME = 3600000;
+ private static final String TEST_USERNAME = "test@example.com";
+
+ private JwtUtil jwtUtil;
+ private SecretKey secretKey;
+
+ @BeforeEach
+ void setUp() {
+ jwtUtil = new JwtUtil();
+ secretKey = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));
+
+ ReflectionTestUtils.setField(jwtUtil, "secret", SECRET);
+ ReflectionTestUtils.setField(jwtUtil, "validityTime", VALIDITY_TIME);
+ ReflectionTestUtils.setField(jwtUtil, "secretKey", secretKey);
+ }
+
+ @Test
+ void generateToken_ShouldCreateValidToken() {
+ String token = jwtUtil.generateToken(TEST_USERNAME);
+
+ assertNotNull(token);
+ assertFalse(token.isEmpty());
+
+ JwtParser parser = Jwts.parser().verifyWith(secretKey).build();
+ Jws claims = parser.parseSignedClaims(token);
+
+ assertEquals(TEST_USERNAME, claims.getPayload().getSubject());
+ assertNotNull(claims.getPayload().getIssuedAt());
+ assertNotNull(claims.getPayload().getExpiration());
+ }
+
+ @Test
+ void getUsername_ShouldReturnCorrectUsername() {
+ String token = jwtUtil.generateToken(TEST_USERNAME);
+
+ String username = jwtUtil.getUsername(token);
+
+ assertEquals(TEST_USERNAME, username);
+ }
+
+ @Test
+ void isValid_ShouldReturnTrueForValidToken() {
+ String token = jwtUtil.generateToken(TEST_USERNAME);
+
+ boolean isValid = jwtUtil.isValid(token);
+
+ assertTrue(isValid);
+ }
+
+ @Test
+ void isValid_ShouldReturnFalseForExpiredToken() throws Exception {
+ JwtUtil expiredJwtUtil = new JwtUtil();
+ ReflectionTestUtils.setField(expiredJwtUtil, "secret", SECRET);
+ ReflectionTestUtils.setField(expiredJwtUtil, "validityTime", -10000L);
+ ReflectionTestUtils.setField(expiredJwtUtil, "secretKey", secretKey);
+
+ String expiredToken = expiredJwtUtil.generateToken(TEST_USERNAME);
+
+ boolean isValid = jwtUtil.isValid(expiredToken);
+
+ assertFalse(isValid);
+ }
+}
diff --git a/src/test/java/com/senkiv/carsharing/security/UserDetailServiceImplTest.java b/src/test/java/com/senkiv/carsharing/security/UserDetailServiceImplTest.java
new file mode 100644
index 0000000..b941f38
--- /dev/null
+++ b/src/test/java/com/senkiv/carsharing/security/UserDetailServiceImplTest.java
@@ -0,0 +1,58 @@
+package com.senkiv.carsharing.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.senkiv.carsharing.model.User;
+import com.senkiv.carsharing.repository.UserRepository;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+
+@ExtendWith(MockitoExtension.class)
+class UserDetailServiceImplTest {
+
+ private static final String TEST_EMAIL = "test@example.com";
+ private static final String USER_NOT_FOUND_MESSAGE = "User with email %s does not exist";
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private User user;
+
+ @InjectMocks
+ private UserDetailServiceImpl userDetailService;
+
+ @Test
+ void loadUserByUsername_WithExistingUser_ShouldReturnUserDetails() {
+ when(userRepository.findByEmail(TEST_EMAIL)).thenReturn(Optional.of(user));
+
+ UserDetails result = userDetailService.loadUserByUsername(TEST_EMAIL);
+
+ assertNotNull(result);
+ assertEquals(user, result);
+ verify(userRepository).findByEmail(TEST_EMAIL);
+ }
+
+ @Test
+ void loadUserByUsername_WithNonExistingUser_ShouldThrowException() {
+ when(userRepository.findByEmail(TEST_EMAIL)).thenReturn(Optional.empty());
+
+ UsernameNotFoundException exception = assertThrows(
+ UsernameNotFoundException.class,
+ () -> userDetailService.loadUserByUsername(TEST_EMAIL)
+ );
+
+ assertEquals(USER_NOT_FOUND_MESSAGE.formatted(TEST_EMAIL), exception.getMessage());
+ verify(userRepository).findByEmail(TEST_EMAIL);
+ }
+}
diff --git a/src/test/java/com/senkiv/carsharing/service/impl/AuthenticationServiceImplTest.java b/src/test/java/com/senkiv/carsharing/service/impl/AuthenticationServiceImplTest.java
new file mode 100644
index 0000000..d424379
--- /dev/null
+++ b/src/test/java/com/senkiv/carsharing/service/impl/AuthenticationServiceImplTest.java
@@ -0,0 +1,78 @@
+package com.senkiv.carsharing.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.senkiv.carsharing.dto.UserLoginRequestDto;
+import com.senkiv.carsharing.dto.UserLoginResponseDto;
+import com.senkiv.carsharing.security.JwtUtil;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+
+@ExtendWith(MockitoExtension.class)
+class AuthenticationServiceImplTest {
+
+ private static final String TEST_EMAIL = "test@example.com";
+ private static final String TEST_PASSWORD = "password";
+ private static final String TEST_TOKEN = "test.jwt.token";
+
+ @Mock
+ private AuthenticationManager authenticationManager;
+
+ @Mock
+ private JwtUtil jwtUtil;
+
+ @Mock
+ private Authentication authentication;
+
+ @InjectMocks
+ private AuthenticationServiceImpl authenticationService;
+
+ @Test
+ void authenticate_ShouldReturnTokenWhenCredentialsAreValid() {
+ UserLoginRequestDto loginRequestDto = new UserLoginRequestDto(TEST_EMAIL, TEST_PASSWORD);
+ UsernamePasswordAuthenticationToken authToken =
+ new UsernamePasswordAuthenticationToken(TEST_EMAIL, TEST_PASSWORD);
+
+ when(authenticationManager.authenticate(authToken)).thenReturn(authentication);
+ when(authentication.getName()).thenReturn(TEST_EMAIL);
+ when(jwtUtil.generateToken(TEST_EMAIL)).thenReturn(TEST_TOKEN);
+
+ UserLoginResponseDto result = authenticationService.authenticate(loginRequestDto);
+
+ assertNotNull(result);
+ assertEquals(TEST_TOKEN, result.token());
+
+ verify(authenticationManager).authenticate(authToken);
+ verify(jwtUtil).generateToken(TEST_EMAIL);
+ }
+
+ @Test
+ void authenticate_ShouldPropagateExceptionWhenAuthenticationFails() {
+ UserLoginRequestDto loginRequestDto = new UserLoginRequestDto(TEST_EMAIL, TEST_PASSWORD);
+ UsernamePasswordAuthenticationToken authToken =
+ new UsernamePasswordAuthenticationToken(TEST_EMAIL, TEST_PASSWORD);
+
+ when(authenticationManager.authenticate(authToken))
+ .thenThrow(new RuntimeException("Authentication failed"));
+
+ Exception exception = assertThrows(
+ RuntimeException.class,
+ () -> authenticationService.authenticate(loginRequestDto)
+ );
+
+ assertEquals("Authentication failed", exception.getMessage());
+ verify(authenticationManager).authenticate(authToken);
+ verifyNoInteractions(jwtUtil);
+ }
+}
diff --git a/src/test/java/com/senkiv/carsharing/service/impl/CarServiceImplTest.java b/src/test/java/com/senkiv/carsharing/service/impl/CarServiceImplTest.java
new file mode 100644
index 0000000..c6447d0
--- /dev/null
+++ b/src/test/java/com/senkiv/carsharing/service/impl/CarServiceImplTest.java
@@ -0,0 +1,227 @@
+package com.senkiv.carsharing.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.senkiv.carsharing.dto.CarRequestDto;
+import com.senkiv.carsharing.dto.CarResponseDto;
+import com.senkiv.carsharing.mapper.CarMapper;
+import com.senkiv.carsharing.model.Car;
+import com.senkiv.carsharing.model.CarType;
+import com.senkiv.carsharing.repository.CarRepository;
+import jakarta.persistence.EntityNotFoundException;
+import java.math.BigDecimal;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class CarServiceImplTest {
+
+ @Mock
+ private CarRepository carRepository;
+
+ @Mock
+ private CarMapper carMapper;
+
+ @InjectMocks
+ private CarServiceImpl carService;
+
+ @Test
+ void create_ShouldReturnCarResponseDto() {
+ CarRequestDto requestDto = new CarRequestDto(
+ "Model 3",
+ "Tesla",
+ CarType.SEDAN,
+ 5,
+ BigDecimal.valueOf(100.00)
+ );
+ Car car = new Car();
+ car.setId(1L);
+ car.setModel(requestDto.model());
+ car.setBrand(requestDto.brand());
+ car.setType(requestDto.type());
+ car.setInventory(requestDto.inventory());
+ car.setDailyFee(requestDto.dailyFee());
+
+ CarResponseDto expectedDto = new CarResponseDto(
+ 1L,
+ requestDto.model(),
+ requestDto.brand(),
+ requestDto.type(),
+ requestDto.inventory(),
+ requestDto.dailyFee()
+ );
+
+ when(carMapper.toModel(requestDto)).thenReturn(car);
+ when(carRepository.save(car)).thenReturn(car);
+ when(carMapper.toDto(car)).thenReturn(expectedDto);
+
+ CarResponseDto actualDto = carService.create(requestDto);
+
+ assertNotNull(actualDto);
+ assertEquals(expectedDto, actualDto);
+ verify(carMapper).toModel(requestDto);
+ verify(carRepository).save(car);
+ verify(carMapper).toDto(car);
+ }
+
+ @Test
+ void getById_WithExistingId_ShouldReturnCarResponseDto() {
+ Long id = 1L;
+ Car car = new Car();
+ car.setId(id);
+ car.setModel("Model 3");
+ car.setBrand("Tesla");
+ car.setType(CarType.SEDAN);
+ car.setInventory(5);
+ car.setDailyFee(BigDecimal.valueOf(100.00));
+
+ CarResponseDto expectedDto = new CarResponseDto(
+ id,
+ "Model 3",
+ "Tesla",
+ CarType.SEDAN,
+ 5,
+ BigDecimal.valueOf(100.00)
+ );
+
+ when(carRepository.findById(id)).thenReturn(Optional.of(car));
+ when(carMapper.toDto(car)).thenReturn(expectedDto);
+
+ CarResponseDto actualDto = carService.getById(id);
+
+ assertNotNull(actualDto);
+ assertEquals(expectedDto, actualDto);
+ verify(carRepository).findById(id);
+ verify(carMapper).toDto(car);
+ }
+
+ @Test
+ void getById_WithNonExistingId_ShouldThrowException() {
+ Long id = 999L;
+ when(carRepository.findById(id)).thenReturn(Optional.empty());
+
+ EntityNotFoundException exception = assertThrows(
+ EntityNotFoundException.class,
+ () -> carService.getById(id)
+ );
+
+ assertEquals("Car with id 999 not found", exception.getMessage());
+ verify(carRepository).findById(id);
+ verifyNoInteractions(carMapper);
+ }
+
+ @Test
+ void update_WithExistingId_ShouldReturnUpdatedCarResponseDto() {
+ Long id = 1L;
+ final CarRequestDto requestDto = new CarRequestDto(
+ "Updated Model",
+ "Updated Brand",
+ CarType.SUV,
+ 10,
+ BigDecimal.valueOf(150.00)
+ );
+
+ Car existingCar = new Car();
+ existingCar.setId(id);
+ existingCar.setModel("Model 3");
+ existingCar.setBrand("Tesla");
+ existingCar.setType(CarType.SEDAN);
+ existingCar.setInventory(5);
+ existingCar.setDailyFee(BigDecimal.valueOf(100.00));
+
+ Car updatedCar = new Car();
+ updatedCar.setId(id);
+ updatedCar.setModel(requestDto.model());
+ updatedCar.setBrand(requestDto.brand());
+ updatedCar.setType(requestDto.type());
+ updatedCar.setInventory(requestDto.inventory());
+ updatedCar.setDailyFee(requestDto.dailyFee());
+
+ CarResponseDto expectedDto = new CarResponseDto(
+ id,
+ requestDto.model(),
+ requestDto.brand(),
+ requestDto.type(),
+ requestDto.inventory(),
+ requestDto.dailyFee()
+ );
+
+ when(carRepository.findById(id)).thenReturn(Optional.of(existingCar));
+ when(carRepository.save(any(Car.class))).thenReturn(updatedCar);
+ when(carMapper.toDto(updatedCar)).thenReturn(expectedDto);
+
+ CarResponseDto actualDto = carService.update(id, requestDto);
+
+ assertNotNull(actualDto);
+ assertEquals(expectedDto, actualDto);
+ verify(carRepository).findById(id);
+ verify(carRepository).save(any(Car.class));
+ verify(carMapper).toDto(updatedCar);
+ }
+
+ @Test
+ void update_WithNonExistingId_ShouldThrowException() {
+ Long id = 999L;
+ CarRequestDto requestDto = new CarRequestDto(
+ "Updated Model",
+ "Updated Brand",
+ CarType.SUV,
+ 10,
+ BigDecimal.valueOf(150.00)
+ );
+
+ when(carRepository.findById(id)).thenReturn(Optional.empty());
+
+ EntityNotFoundException exception = assertThrows(
+ EntityNotFoundException.class,
+ () -> carService.update(id, requestDto)
+ );
+
+ assertEquals("Car with id 999 not found", exception.getMessage());
+ verify(carRepository).findById(id);
+ verifyNoMoreInteractions(carRepository);
+ verifyNoInteractions(carMapper);
+ }
+
+ @Test
+ void delete_WithExistingId_ShouldDeleteCar() {
+ Long id = 1L;
+ Car car = new Car();
+ car.setId(id);
+
+ when(carRepository.findById(id)).thenReturn(Optional.of(car));
+ doNothing().when(carRepository).deleteById(id);
+
+ carService.delete(id);
+
+ verify(carRepository).findById(id);
+ verify(carRepository).deleteById(id);
+ }
+
+ @Test
+ void delete_WithNonExistingId_ShouldThrowException() {
+ Long id = 999L;
+ when(carRepository.findById(id)).thenReturn(Optional.empty());
+
+ EntityNotFoundException exception = assertThrows(
+ EntityNotFoundException.class,
+ () -> carService.delete(id)
+ );
+
+ assertEquals("Car with id 999 not found", exception.getMessage());
+ verify(carRepository).findById(id);
+ verifyNoMoreInteractions(carRepository);
+ }
+}
diff --git a/src/test/java/com/senkiv/carsharing/validation/FieldMatchValidatorTest.java b/src/test/java/com/senkiv/carsharing/validation/FieldMatchValidatorTest.java
new file mode 100644
index 0000000..b1e6b18
--- /dev/null
+++ b/src/test/java/com/senkiv/carsharing/validation/FieldMatchValidatorTest.java
@@ -0,0 +1,112 @@
+package com.senkiv.carsharing.validation;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.senkiv.carsharing.dto.UserRegistrationRequestDto;
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import jakarta.validation.ValidatorFactory;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class FieldMatchValidatorTest {
+
+ private Validator validator;
+
+ @BeforeEach
+ void setUp() {
+ ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
+ validator = factory.getValidator();
+ }
+
+ @Test
+ void shouldValidateWhenFieldsMatch() {
+ UserRegistrationRequestDto dto = new UserRegistrationRequestDto(
+ "John",
+ "Doe",
+ "test@example.com",
+ "password123",
+ "password123"
+ );
+
+ Set> violations = validator.validate(dto);
+
+ assertTrue(violations.isEmpty(), "No violations should be found when fields match");
+ }
+
+ @Test
+ void shouldNotValidateWhenFieldsDoNotMatch() {
+ UserRegistrationRequestDto dto = new UserRegistrationRequestDto(
+ "John",
+ "Doe",
+ "test@example.com",
+ "password123",
+ "differentPassword"
+ );
+
+ Set> violations = validator.validate(dto);
+
+ assertEquals(1, violations.size(), "One violation should be found when fields don't match");
+ ConstraintViolation violation = violations.iterator().next();
+ assertEquals(FieldMatch.FIELDS_ARE_NOT_MATCHING, violation.getMessage(),
+ "Violation message should match the expected message");
+ }
+
+ @Test
+ void shouldValidateWhenBothFieldsAreNull() {
+ UserRegistrationRequestDto dto = new UserRegistrationRequestDto(
+ "John",
+ "Doe",
+ "test@example.com",
+ null,
+ null
+ );
+
+ Set> violations = validator.validate(dto);
+
+ boolean hasFieldMatchViolation = violations.stream()
+ .anyMatch(v -> v.getMessage().equals(FieldMatch.FIELDS_ARE_NOT_MATCHING));
+ assertFalse(hasFieldMatchViolation,
+ "No FieldMatch violation should be found when both fields are null");
+ }
+
+ @Test
+ void shouldValidateWhenBothFieldsAreEmpty() {
+ UserRegistrationRequestDto dto = new UserRegistrationRequestDto(
+ "John",
+ "Doe",
+ "test@example.com",
+ "",
+ ""
+ );
+
+ Set> violations = validator.validate(dto);
+
+ boolean hasFieldMatchViolation = violations.stream()
+ .anyMatch(v -> v.getMessage().equals(FieldMatch.FIELDS_ARE_NOT_MATCHING));
+ assertFalse(hasFieldMatchViolation,
+ "No FieldMatch violation should be found when both fields are empty");
+ }
+
+ @Test
+ void shouldNotValidateWhenOneFieldIsNull() {
+ UserRegistrationRequestDto dto = new UserRegistrationRequestDto(
+ "John",
+ "Doe",
+ "test@example.com",
+ "password123",
+ null
+ );
+
+ Set> violations = validator.validate(dto);
+
+ boolean hasFieldMatchViolation = violations.stream()
+ .anyMatch(v -> v.getMessage().equals(FieldMatch.FIELDS_ARE_NOT_MATCHING));
+ assertTrue(hasFieldMatchViolation,
+ "FieldMatch violation should be found when one field is null");
+ }
+}
diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties
index bc2fdde..58ace3d 100644
--- a/src/test/resources/application.properties
+++ b/src/test/resources/application.properties
@@ -3,3 +3,5 @@ spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
+token.validity=360000
+token.secret=123123123123sercret_key_confidential_token123123123213