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 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[] 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