diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 8ff5094..17638aa 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -34,6 +34,7 @@ jobs: build/classes build/libs build/test-results + build/reports/jacoco retention-days: 1 sonarcloud: @@ -69,7 +70,9 @@ jobs: -Dsonar.host.url=https://sonarcloud.io -Dsonar.organization=${{ secrets.SONAR_ORGANIZATION }} -Dsonar.projectKey=${{ secrets.SONAR_PROJECT_KEY }} - -Dsonar.sources=. + -Dsonar.sources=src/main/java + -Dsonar.tests=src/test/java -Dsonar.java.binaries=build/classes/java/main -Dsonar.java.test.binaries=build/classes/java/test - -Dsonar.junit.reportPaths=build/test-results/test \ No newline at end of file + -Dsonar.junit.reportPaths=build/test-results/test + -Dsonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/test/jacocoTestReport.xml \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 7b3ada2..8bac8b5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { java + jacoco id("org.springframework.boot") version "4.0.1" id("io.spring.dependency-management") version "1.1.7" } @@ -74,4 +75,12 @@ dependencies { tasks.withType { useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required = true + } } diff --git a/src/main/java/com/devoops/user/controller/UserController.java b/src/main/java/com/devoops/user/controller/UserController.java new file mode 100644 index 0000000..f9c872a --- /dev/null +++ b/src/main/java/com/devoops/user/controller/UserController.java @@ -0,0 +1,44 @@ +package com.devoops.user.controller; + +import com.devoops.user.dto.request.ChangePasswordRequest; +import com.devoops.user.dto.request.UpdateUserRequest; +import com.devoops.user.dto.response.AuthenticationResponse; +import com.devoops.user.dto.response.UserResponse; +import com.devoops.user.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/user/me") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @GetMapping + @PreAuthorize("hasAnyRole('HOST', 'GUEST')") + public ResponseEntity getProfile(Authentication auth) { + return ResponseEntity.ok(userService.getProfile(auth)); + } + + @PutMapping + @PreAuthorize("hasAnyRole('HOST', 'GUEST')") + public ResponseEntity updateProfile( + Authentication auth, + @RequestBody @Valid UpdateUserRequest request) { + return ResponseEntity.ok(userService.updateProfile(auth, request)); + } + + @PutMapping("/password") + @PreAuthorize("hasAnyRole('HOST', 'GUEST')") + public ResponseEntity changePassword( + Authentication auth, + @RequestBody @Valid ChangePasswordRequest request) { + userService.changePassword(auth, request); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/devoops/user/dto/request/ChangePasswordRequest.java b/src/main/java/com/devoops/user/dto/request/ChangePasswordRequest.java new file mode 100644 index 0000000..b25b408 --- /dev/null +++ b/src/main/java/com/devoops/user/dto/request/ChangePasswordRequest.java @@ -0,0 +1,9 @@ +package com.devoops.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ChangePasswordRequest( + @NotBlank String currentPassword, + @Size(min = 8) String newPassword +) {} diff --git a/src/main/java/com/devoops/user/dto/request/UpdateUserRequest.java b/src/main/java/com/devoops/user/dto/request/UpdateUserRequest.java new file mode 100644 index 0000000..37fad62 --- /dev/null +++ b/src/main/java/com/devoops/user/dto/request/UpdateUserRequest.java @@ -0,0 +1,12 @@ +package com.devoops.user.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; + +public record UpdateUserRequest( + @Size(min = 3, max = 50) String username, + @Email String email, + @Size(max = 100) String firstName, + @Size(max = 100) String lastName, + @Size(max = 150) String residence +) {} diff --git a/src/main/java/com/devoops/user/entity/BaseEntity.java b/src/main/java/com/devoops/user/entity/BaseEntity.java new file mode 100644 index 0000000..7b5e28d --- /dev/null +++ b/src/main/java/com/devoops/user/entity/BaseEntity.java @@ -0,0 +1,36 @@ +package com.devoops.user.entity; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@MappedSuperclass +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @CreationTimestamp + @Column(updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + @Builder.Default + @Column(nullable = false) + private boolean isDeleted = false; +} diff --git a/src/main/java/com/devoops/user/entity/User.java b/src/main/java/com/devoops/user/entity/User.java index 9d42178..d9f0934 100644 --- a/src/main/java/com/devoops/user/entity/User.java +++ b/src/main/java/com/devoops/user/entity/User.java @@ -2,29 +2,26 @@ import jakarta.persistence.*; import lombok.*; +import lombok.experimental.SuperBuilder; import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.SQLRestriction; import org.hibernate.type.SqlTypes; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import java.time.LocalDateTime; import java.util.Collection; import java.util.List; -import java.util.UUID; @Entity @Table(name = "users") +@SQLRestriction("is_deleted = false") @Getter @Setter @NoArgsConstructor @AllArgsConstructor -@Builder -public class User implements UserDetails { - - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; +@SuperBuilder +public class User extends BaseEntity implements UserDetails { @Column(nullable = false, unique = true, length = 50) private String username; @@ -49,24 +46,8 @@ public class User implements UserDetails { @JdbcTypeCode(SqlTypes.NAMED_ENUM) private Role role; - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; - - @PrePersist - protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); - } - - @PreUpdate - protected void onUpdate() { - updatedAt = LocalDateTime.now(); - } - @Override + @NonNull public Collection getAuthorities() { return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); } diff --git a/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java index 09457a5..9e36899 100644 --- a/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java @@ -18,6 +18,14 @@ @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(UserNotFoundException.class) + public ProblemDetail handleUserNotFound(UserNotFoundException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + problemDetail.setTitle("User Not Found"); + problemDetail.setProperty("timestamp", Instant.now()); + return problemDetail; + } + @ExceptionHandler(UserAlreadyExistsException.class) public ProblemDetail handleUserAlreadyExists(UserAlreadyExistsException ex) { ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage()); @@ -34,6 +42,14 @@ public ProblemDetail handleInvalidCredentials(InvalidCredentialsException ex) { return problemDetail; } + @ExceptionHandler(InvalidPasswordException.class) + public ProblemDetail handleInvalidPassword(InvalidPasswordException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + problemDetail.setTitle("Invalid Password"); + problemDetail.setProperty("timestamp", Instant.now()); + return problemDetail; + } + @ExceptionHandler({BadCredentialsException.class, UsernameNotFoundException.class}) public ProblemDetail handleAuthenticationFailure(Exception ex) { ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Invalid credentials"); diff --git a/src/main/java/com/devoops/user/exception/InvalidPasswordException.java b/src/main/java/com/devoops/user/exception/InvalidPasswordException.java new file mode 100644 index 0000000..01159ff --- /dev/null +++ b/src/main/java/com/devoops/user/exception/InvalidPasswordException.java @@ -0,0 +1,7 @@ +package com.devoops.user.exception; + +public class InvalidPasswordException extends RuntimeException { + public InvalidPasswordException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/user/exception/UserNotFoundException.java b/src/main/java/com/devoops/user/exception/UserNotFoundException.java new file mode 100644 index 0000000..345dee7 --- /dev/null +++ b/src/main/java/com/devoops/user/exception/UserNotFoundException.java @@ -0,0 +1,7 @@ +package com.devoops.user.exception; + +public class UserNotFoundException extends RuntimeException { + public UserNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/user/mapper/UserMapper.java b/src/main/java/com/devoops/user/mapper/UserMapper.java index a6019b1..d6a9581 100644 --- a/src/main/java/com/devoops/user/mapper/UserMapper.java +++ b/src/main/java/com/devoops/user/mapper/UserMapper.java @@ -15,5 +15,6 @@ public interface UserMapper { @Mapping(target = "password", ignore = true) @Mapping(target = "createdAt", ignore = true) @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "isDeleted", ignore = true) User toEntity(RegisterRequest request); } diff --git a/src/main/java/com/devoops/user/service/UserService.java b/src/main/java/com/devoops/user/service/UserService.java new file mode 100644 index 0000000..36c6221 --- /dev/null +++ b/src/main/java/com/devoops/user/service/UserService.java @@ -0,0 +1,73 @@ +package com.devoops.user.service; + +import com.devoops.user.dto.request.ChangePasswordRequest; +import com.devoops.user.dto.request.UpdateUserRequest; +import com.devoops.user.dto.response.AuthenticationResponse; +import com.devoops.user.dto.response.UserResponse; +import com.devoops.user.entity.User; +import com.devoops.user.exception.InvalidCredentialsException; +import com.devoops.user.exception.InvalidPasswordException; +import com.devoops.user.exception.UserAlreadyExistsException; +import com.devoops.user.exception.UserNotFoundException; +import com.devoops.user.mapper.UserMapper; +import com.devoops.user.repository.UserRepository; +import com.devoops.user.security.JwtService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final UserMapper userMapper; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + + public UserResponse getProfile(Authentication auth) { + User user = (User) auth.getPrincipal(); + return userMapper.toUserResponse(user); + } + + public AuthenticationResponse updateProfile(Authentication auth, UpdateUserRequest request) { + User user = (User) auth.getPrincipal(); + if (user == null) + throw new UserNotFoundException("User does not exist"); + + if (request.username() != null && !request.username().equals(user.getUsername())) { + if (userRepository.existsByUsername(request.username())) { + throw new UserAlreadyExistsException("Username already taken"); + } + user.setUsername(request.username()); + } + + if (request.email() != null && !request.email().equals(user.getEmail())) { + if (userRepository.existsByEmail(request.email())) { + throw new UserAlreadyExistsException("Email already taken"); + } + user.setEmail(request.email()); + } + + if (request.firstName() != null) user.setFirstName(request.firstName()); + if (request.lastName() != null) user.setLastName(request.lastName()); + if (request.residence() != null) user.setResidence(request.residence()); + + User saved = userRepository.save(user); + return new AuthenticationResponse(jwtService.generateToken(saved), jwtService.getExpirationTime(), userMapper.toUserResponse(saved)); + } + + public void changePassword(Authentication auth, ChangePasswordRequest request) { + User user = (User) auth.getPrincipal(); + if (user == null) + throw new UserNotFoundException("User does not exist"); + + if (!passwordEncoder.matches(request.currentPassword(), user.getPassword())) { + throw new InvalidPasswordException("Current password is incorrect"); + } + + user.setPassword(passwordEncoder.encode(request.newPassword())); + userRepository.save(user); + } +} diff --git a/src/main/resources/db/migration/V2__add_soft_delete.sql b/src/main/resources/db/migration/V2__add_soft_delete.sql new file mode 100644 index 0000000..6091b32 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_soft_delete.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/test/java/com/devoops/user/controller/UserControllerTest.java b/src/test/java/com/devoops/user/controller/UserControllerTest.java new file mode 100644 index 0000000..fccc6d5 --- /dev/null +++ b/src/test/java/com/devoops/user/controller/UserControllerTest.java @@ -0,0 +1,296 @@ +package com.devoops.user.controller; + +import com.devoops.user.dto.request.ChangePasswordRequest; +import com.devoops.user.dto.request.UpdateUserRequest; +import com.devoops.user.dto.response.AuthenticationResponse; +import com.devoops.user.dto.response.UserResponse; +import com.devoops.user.entity.Role; +import com.devoops.user.exception.GlobalExceptionHandler; +import com.devoops.user.exception.InvalidCredentialsException; +import com.devoops.user.exception.UserAlreadyExistsException; +import com.devoops.user.service.UserService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.security.core.Authentication; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class UserControllerTest { + + @Mock + private UserService userService; + + @InjectMocks + private UserController userController; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + private UserResponse userResponse; + private AuthenticationResponse authenticationResponse; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(userController) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + objectMapper = new ObjectMapper(); + + userResponse = new UserResponse( + UUID.randomUUID(), + "testuser", + "test@example.com", + "Test", + "User", + "Test City", + Role.GUEST + ); + + authenticationResponse = new AuthenticationResponse( + "jwt-token-here", + 86400000L, + userResponse + ); + } + + @Nested + @DisplayName("GET /api/user/me — getProfile") + class GetProfileTests { + + @Test + @DisplayName("Should return 200 OK with UserResponse when auth is valid") + void getProfile_WithValidAuth_ReturnsUserResponse() throws Exception { + // Given + Authentication auth = mock(Authentication.class); + when(userService.getProfile(any(Authentication.class))).thenReturn(userResponse); + + // When/Then + mockMvc.perform(get("/api/user/me") + .principal(auth)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.username").value("testuser")) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.role").value("GUEST")); + } + + @Test + @DisplayName("Should return 401 when service throws InvalidCredentialsException") + void getProfile_WithoutAuth_Returns401() throws Exception { + // Given + Authentication auth = mock(Authentication.class); + when(userService.getProfile(any(Authentication.class))) + .thenThrow(new InvalidCredentialsException("Unauthorized")); + + // When/Then + mockMvc.perform(get("/api/user/me") + .principal(auth)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.title").value("Invalid Credentials")); + } + } + + @Nested + @DisplayName("PUT /api/user/me — updateProfile") + class UpdateProfileTests { + + @Test + @DisplayName("Should return 200 OK with AuthenticationResponse when request is valid") + void updateProfile_WithValidRequest_ReturnsAuthenticationResponse() throws Exception { + // Given + UpdateUserRequest request = new UpdateUserRequest( + "newusername", "new@example.com", "New", "Name", "New City" + ); + Authentication auth = mock(Authentication.class); + when(userService.updateProfile(any(Authentication.class), any(UpdateUserRequest.class))) + .thenReturn(authenticationResponse); + + // When/Then + mockMvc.perform(put("/api/user/me") + .principal(auth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.accessToken").value("jwt-token-here")) + .andExpect(jsonPath("$.tokenType").value("Bearer")) + .andExpect(jsonPath("$.user.username").value("testuser")); + } + + @Test + @DisplayName("Should return 409 CONFLICT when username is already taken") + void updateProfile_WithUsernameTaken_Returns409() throws Exception { + // Given + UpdateUserRequest request = new UpdateUserRequest( + "takenuser", null, null, null, null + ); + Authentication auth = mock(Authentication.class); + when(userService.updateProfile(any(Authentication.class), any(UpdateUserRequest.class))) + .thenThrow(new UserAlreadyExistsException("Username already taken")); + + // When/Then + mockMvc.perform(put("/api/user/me") + .principal(auth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.title").value("User Already Exists")) + .andExpect(jsonPath("$.detail").value("Username already taken")); + } + + @Test + @DisplayName("Should return 409 CONFLICT when email is already taken") + void updateProfile_WithEmailTaken_Returns409() throws Exception { + // Given + UpdateUserRequest request = new UpdateUserRequest( + null, "taken@example.com", null, null, null + ); + Authentication auth = mock(Authentication.class); + when(userService.updateProfile(any(Authentication.class), any(UpdateUserRequest.class))) + .thenThrow(new UserAlreadyExistsException("Email already taken")); + + // When/Then + mockMvc.perform(put("/api/user/me") + .principal(auth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.detail").value("Email already taken")); + } + + @Test + @DisplayName("Should return 400 BAD REQUEST when email is invalid") + void updateProfile_WithInvalidEmail_Returns400() throws Exception { + // Given + String invalidRequest = """ + { + "email": "not-an-email" + } + """; + Authentication auth = mock(Authentication.class); + + // When/Then + mockMvc.perform(put("/api/user/me") + .principal(auth) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidRequest)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Should return 400 BAD REQUEST when username is too short") + void updateProfile_WithUsernameTooShort_Returns400() throws Exception { + // Given + String invalidRequest = """ + { + "username": "ab" + } + """; + Authentication auth = mock(Authentication.class); + + // When/Then + mockMvc.perform(put("/api/user/me") + .principal(auth) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidRequest)) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("PUT /api/user/me/password — changePassword") + class ChangePasswordTests { + + @Test + @DisplayName("Should return 204 NO CONTENT when password change is successful") + void changePassword_WithValidRequest_Returns204() throws Exception { + // Given + ChangePasswordRequest request = new ChangePasswordRequest("currentPass1", "newPassword123"); + Authentication auth = mock(Authentication.class); + + // When/Then + mockMvc.perform(put("/api/user/me/password") + .principal(auth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("Should return 401 UNAUTHORIZED when current password is incorrect") + void changePassword_WithInvalidCurrentPassword_Returns401() throws Exception { + // Given + ChangePasswordRequest request = new ChangePasswordRequest("wrongPass", "newPassword123"); + Authentication auth = mock(Authentication.class); + doThrow(new InvalidCredentialsException("Current password is incorrect")) + .when(userService).changePassword(any(Authentication.class), any(ChangePasswordRequest.class)); + + // When/Then + mockMvc.perform(put("/api/user/me/password") + .principal(auth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.detail").value("Current password is incorrect")); + } + + @Test + @DisplayName("Should return 400 BAD REQUEST when current password is blank") + void changePassword_WithBlankCurrentPassword_Returns400() throws Exception { + // Given + String invalidRequest = """ + { + "currentPassword": "", + "newPassword": "newPassword123" + } + """; + Authentication auth = mock(Authentication.class); + + // When/Then + mockMvc.perform(put("/api/user/me/password") + .principal(auth) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidRequest)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Should return 400 BAD REQUEST when new password is too short") + void changePassword_WithShortNewPassword_Returns400() throws Exception { + // Given + String invalidRequest = """ + { + "currentPassword": "current123", + "newPassword": "short" + } + """; + Authentication auth = mock(Authentication.class); + + // When/Then + mockMvc.perform(put("/api/user/me/password") + .principal(auth) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidRequest)) + .andExpect(status().isBadRequest()); + } + } +} diff --git a/src/test/java/com/devoops/user/service/UserServiceTest.java b/src/test/java/com/devoops/user/service/UserServiceTest.java new file mode 100644 index 0000000..ea901a2 --- /dev/null +++ b/src/test/java/com/devoops/user/service/UserServiceTest.java @@ -0,0 +1,297 @@ +package com.devoops.user.service; + +import com.devoops.user.dto.request.ChangePasswordRequest; +import com.devoops.user.dto.request.UpdateUserRequest; +import com.devoops.user.dto.response.AuthenticationResponse; +import com.devoops.user.dto.response.UserResponse; +import com.devoops.user.entity.Role; +import com.devoops.user.entity.User; +import com.devoops.user.exception.InvalidPasswordException; +import com.devoops.user.exception.UserAlreadyExistsException; +import com.devoops.user.exception.UserNotFoundException; +import com.devoops.user.mapper.UserMapper; +import com.devoops.user.repository.UserRepository; +import com.devoops.user.security.JwtService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserMapper userMapper; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private JwtService jwtService; + + @InjectMocks + private UserService userService; + + private User testUser; + private UserResponse userResponse; + + @BeforeEach + void setUp() { + testUser = buildTestUser(); + + userResponse = new UserResponse( + UUID.randomUUID(), + "testuser", + "test@example.com", + "Test", + "User", + "Test City", + Role.GUEST + ); + } + + private User buildTestUser() { + return User.builder() + .username("testuser") + .password("encoded_password") + .email("test@example.com") + .firstName("Test") + .lastName("User") + .residence("Test City") + .role(Role.GUEST) + .build(); + } + + @Nested + @DisplayName("getProfile Tests") + class GetProfileTests { + + @Test + @DisplayName("Should return UserResponse when auth is valid") + void getProfile_WithValidAuth_ReturnsUserResponse() { + // Given + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(testUser); + when(userMapper.toUserResponse(testUser)).thenReturn(userResponse); + + // When + UserResponse result = userService.getProfile(auth); + + // Then + assertThat(result).isNotNull(); + assertThat(result.username()).isEqualTo("testuser"); + assertThat(result.email()).isEqualTo("test@example.com"); + verify(userMapper).toUserResponse(testUser); + } + } + + @Nested + @DisplayName("updateProfile Tests") + class UpdateProfileTests { + + @Test + @DisplayName("Should update username and return new token when username is new") + void updateProfile_WithNewUsername_UpdatesAndReturnsToken() { + // Given + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(testUser); + UpdateUserRequest request = new UpdateUserRequest("newuser", null, null, null, null); + when(userRepository.existsByUsername("newuser")).thenReturn(false); + when(userRepository.save(any(User.class))).thenReturn(testUser); + when(jwtService.generateToken(any(User.class))).thenReturn("new-token"); + when(jwtService.getExpirationTime()).thenReturn(86400000L); + when(userMapper.toUserResponse(any(User.class))).thenReturn(userResponse); + + // When + AuthenticationResponse result = userService.updateProfile(auth, request); + + // Then + assertThat(result).isNotNull(); + assertThat(result.accessToken()).isEqualTo("new-token"); + verify(userRepository).existsByUsername("newuser"); + verify(userRepository).save(testUser); + } + + @Test + @DisplayName("Should throw UserAlreadyExistsException when username is taken") + void updateProfile_WithExistingUsername_ThrowsUserAlreadyExistsException() { + // Given + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(testUser); + UpdateUserRequest request = new UpdateUserRequest("takenuser", null, null, null, null); + when(userRepository.existsByUsername("takenuser")).thenReturn(true); + + // When/Then + assertThatThrownBy(() -> userService.updateProfile(auth, request)) + .isInstanceOf(UserAlreadyExistsException.class) + .hasMessageContaining("Username already taken"); + + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("Should update email and return new token when email is new") + void updateProfile_WithNewEmail_UpdatesAndReturnsToken() { + // Given + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(testUser); + UpdateUserRequest request = new UpdateUserRequest(null, "new@example.com", null, null, null); + when(userRepository.existsByEmail("new@example.com")).thenReturn(false); + when(userRepository.save(any(User.class))).thenReturn(testUser); + when(jwtService.generateToken(any(User.class))).thenReturn("new-token"); + when(jwtService.getExpirationTime()).thenReturn(86400000L); + when(userMapper.toUserResponse(any(User.class))).thenReturn(userResponse); + + // When + AuthenticationResponse result = userService.updateProfile(auth, request); + + // Then + assertThat(result).isNotNull(); + verify(userRepository).existsByEmail("new@example.com"); + verify(userRepository).save(testUser); + } + + @Test + @DisplayName("Should throw UserAlreadyExistsException when email is taken") + void updateProfile_WithExistingEmail_ThrowsUserAlreadyExistsException() { + // Given + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(testUser); + UpdateUserRequest request = new UpdateUserRequest(null, "taken@example.com", null, null, null); + when(userRepository.existsByEmail("taken@example.com")).thenReturn(true); + + // When/Then + assertThatThrownBy(() -> userService.updateProfile(auth, request)) + .isInstanceOf(UserAlreadyExistsException.class) + .hasMessageContaining("Email already taken"); + + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("Should skip username check when username is the same as current") + void updateProfile_WithSameUsername_SkipsUsernameCheck() { + // Given + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(testUser); + UpdateUserRequest request = new UpdateUserRequest("testuser", null, null, null, null); + when(userRepository.save(any(User.class))).thenReturn(testUser); + when(jwtService.generateToken(any(User.class))).thenReturn("token"); + when(jwtService.getExpirationTime()).thenReturn(86400000L); + when(userMapper.toUserResponse(any(User.class))).thenReturn(userResponse); + + // When + userService.updateProfile(auth, request); + + // Then + verify(userRepository, never()).existsByUsername(anyString()); + } + + @Test + @DisplayName("Should only update non-null fields") + void updateProfile_WithNullFields_OnlyUpdatesNonNullFields() { + // Given + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(testUser); + UpdateUserRequest request = new UpdateUserRequest(null, null, "UpdatedFirst", null, null); + when(userRepository.save(any(User.class))).thenReturn(testUser); + when(jwtService.generateToken(any(User.class))).thenReturn("token"); + when(jwtService.getExpirationTime()).thenReturn(86400000L); + when(userMapper.toUserResponse(any(User.class))).thenReturn(userResponse); + + // When + userService.updateProfile(auth, request); + + // Then + assertThat(testUser.getUsername()).isEqualTo("testuser"); + assertThat(testUser.getEmail()).isEqualTo("test@example.com"); + assertThat(testUser.getFirstName()).isEqualTo("UpdatedFirst"); + verify(userRepository).save(testUser); + } + + @Test + @DisplayName("Should throw UserNotFoundException when principal is null") + void updateProfile_WithNullPrincipal_ThrowsUserNotFoundException() { + // Given + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(null); + UpdateUserRequest request = new UpdateUserRequest("newuser", null, null, null, null); + + // When/Then + assertThatThrownBy(() -> userService.updateProfile(auth, request)) + .isInstanceOf(UserNotFoundException.class) + .hasMessageContaining("User does not exist"); + } + } + + @Nested + @DisplayName("changePassword Tests") + class ChangePasswordTests { + + @Test + @DisplayName("Should encode and save new password when current password matches") + void changePassword_WithValidPassword_EncodesAndSaves() { + // Given + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(testUser); + ChangePasswordRequest request = new ChangePasswordRequest("current", "newpassword123"); + when(passwordEncoder.matches("current", "encoded_password")).thenReturn(true); + when(passwordEncoder.encode("newpassword123")).thenReturn("new_encoded"); + + // When + userService.changePassword(auth, request); + + // Then + assertThat(testUser.getPassword()).isEqualTo("new_encoded"); + verify(userRepository).save(testUser); + } + + @Test + @DisplayName("Should throw InvalidPasswordException when current password is wrong") + void changePassword_WithIncorrectCurrentPassword_ThrowsInvalidPasswordException() { + // Given + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(testUser); + ChangePasswordRequest request = new ChangePasswordRequest("wrong", "newpassword123"); + when(passwordEncoder.matches("wrong", "encoded_password")).thenReturn(false); + + // When/Then + assertThatThrownBy(() -> userService.changePassword(auth, request)) + .isInstanceOf(InvalidPasswordException.class) + .hasMessageContaining("Current password is incorrect"); + + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("Should throw UserNotFoundException when principal is null") + void changePassword_WithNullPrincipal_ThrowsUserNotFoundException() { + // Given + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(null); + ChangePasswordRequest request = new ChangePasswordRequest("current", "newpassword123"); + + // When/Then + assertThatThrownBy(() -> userService.changePassword(auth, request)) + .isInstanceOf(UserNotFoundException.class) + .hasMessageContaining("User does not exist"); + } + } +}