From c5dcdeb7a91628d5a336ca9ff2fc0c5b7f5f93e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sun, 8 Feb 2026 19:27:58 +0100 Subject: [PATCH 1/8] feat: Add user profile APIs, soft-delete support Introduce user profile endpoints and related service/DTO/entity changes. Adds UserController (/api/user/me) with GET, PUT and password update endpoints guarded by roles; new request DTOs UpdateUserRequest and ChangePasswordRequest with validation. Introduce BaseEntity (UUID id, createdAt, updatedAt, isDeleted) and update User to extend it; apply SQL restriction to exclude soft-deleted users and update mapper to ignore isDeleted. Add UserService implementing getProfile, updateProfile (checks for username/email conflicts, persists changes and returns refreshed JWT) and changePassword (validates current password). Add UserNotFoundException and register handler in GlobalExceptionHandler. Add Flyway migration V2 to add is_deleted column to users table. --- .../user/controller/UserController.java | 44 ++++++++++++ .../dto/request/ChangePasswordRequest.java | 9 +++ .../user/dto/request/UpdateUserRequest.java | 12 ++++ .../com/devoops/user/entity/BaseEntity.java | 36 ++++++++++ .../java/com/devoops/user/entity/User.java | 31 ++------ .../exception/GlobalExceptionHandler.java | 8 +++ .../user/exception/UserNotFoundException.java | 7 ++ .../com/devoops/user/mapper/UserMapper.java | 1 + .../com/devoops/user/service/UserService.java | 72 +++++++++++++++++++ .../db/migration/V2__add_soft_delete.sql | 1 + 10 files changed, 196 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/devoops/user/controller/UserController.java create mode 100644 src/main/java/com/devoops/user/dto/request/ChangePasswordRequest.java create mode 100644 src/main/java/com/devoops/user/dto/request/UpdateUserRequest.java create mode 100644 src/main/java/com/devoops/user/entity/BaseEntity.java create mode 100644 src/main/java/com/devoops/user/exception/UserNotFoundException.java create mode 100644 src/main/java/com/devoops/user/service/UserService.java create mode 100644 src/main/resources/db/migration/V2__add_soft_delete.sql 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..dcebe34 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()); 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..27939b8 --- /dev/null +++ b/src/main/java/com/devoops/user/service/UserService.java @@ -0,0 +1,72 @@ +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.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 InvalidCredentialsException("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; From c6cb39cee35275747598617f07dd375abcef4914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sun, 8 Feb 2026 19:42:45 +0100 Subject: [PATCH 2/8] feat: Add unit tests for UserController and UserService Introduce comprehensive JUnit tests for user controller and service layers. Adds UserControllerTest (MockMvc tests covering getProfile, updateProfile and changePassword with success and error cases, including exception handling via GlobalExceptionHandler) and UserServiceTest (unit tests for getProfile, updateProfile and changePassword covering success paths, validation, and exceptions like UserAlreadyExistsException, InvalidCredentialsException, and UserNotFoundException). Tests use Mockito to mock dependencies and verify behavior and side effects. --- .../user/controller/UserControllerTest.java | 296 +++++++++++++++++ .../devoops/user/service/UserServiceTest.java | 297 ++++++++++++++++++ 2 files changed, 593 insertions(+) create mode 100644 src/test/java/com/devoops/user/controller/UserControllerTest.java create mode 100644 src/test/java/com/devoops/user/service/UserServiceTest.java 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..4eac2d3 --- /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.InvalidCredentialsException; +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 InvalidCredentialsException when current password is wrong") + void changePassword_WithIncorrectCurrentPassword_ThrowsInvalidCredentialsException() { + // 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(InvalidCredentialsException.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"); + } + } +} From f5918549db70a5094ceb3e576a034f02472bf234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sun, 8 Feb 2026 21:11:31 +0100 Subject: [PATCH 3/8] fix: Modified changePassword to use new InvalidPasswordException that returns 400 instead of 401 --- .../devoops/user/exception/GlobalExceptionHandler.java | 8 ++++++++ .../devoops/user/exception/InvalidPasswordException.java | 7 +++++++ src/main/java/com/devoops/user/service/UserService.java | 3 ++- .../java/com/devoops/user/service/UserServiceTest.java | 8 ++++---- 4 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/devoops/user/exception/InvalidPasswordException.java diff --git a/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java index dcebe34..9e36899 100644 --- a/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java @@ -42,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/service/UserService.java b/src/main/java/com/devoops/user/service/UserService.java index 27939b8..36c6221 100644 --- a/src/main/java/com/devoops/user/service/UserService.java +++ b/src/main/java/com/devoops/user/service/UserService.java @@ -6,6 +6,7 @@ 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; @@ -63,7 +64,7 @@ public void changePassword(Authentication auth, ChangePasswordRequest request) { throw new UserNotFoundException("User does not exist"); if (!passwordEncoder.matches(request.currentPassword(), user.getPassword())) { - throw new InvalidCredentialsException("Current password is incorrect"); + throw new InvalidPasswordException("Current password is incorrect"); } user.setPassword(passwordEncoder.encode(request.newPassword())); diff --git a/src/test/java/com/devoops/user/service/UserServiceTest.java b/src/test/java/com/devoops/user/service/UserServiceTest.java index 4eac2d3..ea901a2 100644 --- a/src/test/java/com/devoops/user/service/UserServiceTest.java +++ b/src/test/java/com/devoops/user/service/UserServiceTest.java @@ -6,7 +6,7 @@ import com.devoops.user.dto.response.UserResponse; import com.devoops.user.entity.Role; 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; @@ -264,8 +264,8 @@ void changePassword_WithValidPassword_EncodesAndSaves() { } @Test - @DisplayName("Should throw InvalidCredentialsException when current password is wrong") - void changePassword_WithIncorrectCurrentPassword_ThrowsInvalidCredentialsException() { + @DisplayName("Should throw InvalidPasswordException when current password is wrong") + void changePassword_WithIncorrectCurrentPassword_ThrowsInvalidPasswordException() { // Given Authentication auth = mock(Authentication.class); when(auth.getPrincipal()).thenReturn(testUser); @@ -274,7 +274,7 @@ void changePassword_WithIncorrectCurrentPassword_ThrowsInvalidCredentialsExcepti // When/Then assertThatThrownBy(() -> userService.changePassword(auth, request)) - .isInstanceOf(InvalidCredentialsException.class) + .isInstanceOf(InvalidPasswordException.class) .hasMessageContaining("Current password is incorrect"); verify(userRepository, never()).save(any()); From 10c6c48ea69bd179e31ecfd5d77b169e4c6a25e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sun, 8 Feb 2026 19:27:58 +0100 Subject: [PATCH 4/8] feat: Add user profile APIs, soft-delete support Introduce user profile endpoints and related service/DTO/entity changes. Adds UserController (/api/user/me) with GET, PUT and password update endpoints guarded by roles; new request DTOs UpdateUserRequest and ChangePasswordRequest with validation. Introduce BaseEntity (UUID id, createdAt, updatedAt, isDeleted) and update User to extend it; apply SQL restriction to exclude soft-deleted users and update mapper to ignore isDeleted. Add UserService implementing getProfile, updateProfile (checks for username/email conflicts, persists changes and returns refreshed JWT) and changePassword (validates current password). Add UserNotFoundException and register handler in GlobalExceptionHandler. Add Flyway migration V2 to add is_deleted column to users table. --- .../user/controller/UserController.java | 44 ++++++++++++ .../dto/request/ChangePasswordRequest.java | 9 +++ .../user/dto/request/UpdateUserRequest.java | 12 ++++ .../com/devoops/user/entity/BaseEntity.java | 36 ++++++++++ .../java/com/devoops/user/entity/User.java | 31 ++------ .../exception/GlobalExceptionHandler.java | 8 +++ .../user/exception/UserNotFoundException.java | 7 ++ .../com/devoops/user/mapper/UserMapper.java | 1 + .../com/devoops/user/service/UserService.java | 72 +++++++++++++++++++ .../db/migration/V2__add_soft_delete.sql | 1 + 10 files changed, 196 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/devoops/user/controller/UserController.java create mode 100644 src/main/java/com/devoops/user/dto/request/ChangePasswordRequest.java create mode 100644 src/main/java/com/devoops/user/dto/request/UpdateUserRequest.java create mode 100644 src/main/java/com/devoops/user/entity/BaseEntity.java create mode 100644 src/main/java/com/devoops/user/exception/UserNotFoundException.java create mode 100644 src/main/java/com/devoops/user/service/UserService.java create mode 100644 src/main/resources/db/migration/V2__add_soft_delete.sql 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..dcebe34 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()); 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..27939b8 --- /dev/null +++ b/src/main/java/com/devoops/user/service/UserService.java @@ -0,0 +1,72 @@ +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.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 InvalidCredentialsException("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; From f598d218a2418c51f4cd22ea81ddfbb44cf16047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sun, 8 Feb 2026 19:42:45 +0100 Subject: [PATCH 5/8] feat: Add unit tests for UserController and UserService Introduce comprehensive JUnit tests for user controller and service layers. Adds UserControllerTest (MockMvc tests covering getProfile, updateProfile and changePassword with success and error cases, including exception handling via GlobalExceptionHandler) and UserServiceTest (unit tests for getProfile, updateProfile and changePassword covering success paths, validation, and exceptions like UserAlreadyExistsException, InvalidCredentialsException, and UserNotFoundException). Tests use Mockito to mock dependencies and verify behavior and side effects. --- .../user/controller/UserControllerTest.java | 296 +++++++++++++++++ .../devoops/user/service/UserServiceTest.java | 297 ++++++++++++++++++ 2 files changed, 593 insertions(+) create mode 100644 src/test/java/com/devoops/user/controller/UserControllerTest.java create mode 100644 src/test/java/com/devoops/user/service/UserServiceTest.java 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..4eac2d3 --- /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.InvalidCredentialsException; +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 InvalidCredentialsException when current password is wrong") + void changePassword_WithIncorrectCurrentPassword_ThrowsInvalidCredentialsException() { + // 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(InvalidCredentialsException.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"); + } + } +} From 6fc3993efb1fbcad5ec139932c95afd7efc59159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sun, 8 Feb 2026 21:11:31 +0100 Subject: [PATCH 6/8] fix: Modified changePassword to use new InvalidPasswordException that returns 400 instead of 401 --- .../devoops/user/exception/GlobalExceptionHandler.java | 8 ++++++++ .../devoops/user/exception/InvalidPasswordException.java | 7 +++++++ src/main/java/com/devoops/user/service/UserService.java | 3 ++- .../java/com/devoops/user/service/UserServiceTest.java | 8 ++++---- 4 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/devoops/user/exception/InvalidPasswordException.java diff --git a/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java index dcebe34..9e36899 100644 --- a/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java @@ -42,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/service/UserService.java b/src/main/java/com/devoops/user/service/UserService.java index 27939b8..36c6221 100644 --- a/src/main/java/com/devoops/user/service/UserService.java +++ b/src/main/java/com/devoops/user/service/UserService.java @@ -6,6 +6,7 @@ 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; @@ -63,7 +64,7 @@ public void changePassword(Authentication auth, ChangePasswordRequest request) { throw new UserNotFoundException("User does not exist"); if (!passwordEncoder.matches(request.currentPassword(), user.getPassword())) { - throw new InvalidCredentialsException("Current password is incorrect"); + throw new InvalidPasswordException("Current password is incorrect"); } user.setPassword(passwordEncoder.encode(request.newPassword())); diff --git a/src/test/java/com/devoops/user/service/UserServiceTest.java b/src/test/java/com/devoops/user/service/UserServiceTest.java index 4eac2d3..ea901a2 100644 --- a/src/test/java/com/devoops/user/service/UserServiceTest.java +++ b/src/test/java/com/devoops/user/service/UserServiceTest.java @@ -6,7 +6,7 @@ import com.devoops.user.dto.response.UserResponse; import com.devoops.user.entity.Role; 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; @@ -264,8 +264,8 @@ void changePassword_WithValidPassword_EncodesAndSaves() { } @Test - @DisplayName("Should throw InvalidCredentialsException when current password is wrong") - void changePassword_WithIncorrectCurrentPassword_ThrowsInvalidCredentialsException() { + @DisplayName("Should throw InvalidPasswordException when current password is wrong") + void changePassword_WithIncorrectCurrentPassword_ThrowsInvalidPasswordException() { // Given Authentication auth = mock(Authentication.class); when(auth.getPrincipal()).thenReturn(testUser); @@ -274,7 +274,7 @@ void changePassword_WithIncorrectCurrentPassword_ThrowsInvalidCredentialsExcepti // When/Then assertThatThrownBy(() -> userService.changePassword(auth, request)) - .isInstanceOf(InvalidCredentialsException.class) + .isInstanceOf(InvalidPasswordException.class) .hasMessageContaining("Current password is incorrect"); verify(userRepository, never()).save(any()); From 5b98da2b19f20603c2d1c77f9dd898ce1ed5d861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Wed, 11 Feb 2026 19:29:46 +0100 Subject: [PATCH 7/8] fix: Jacoco in pipeline --- .github/workflows/pr-check.yml | 4 +++- build.gradle.kts | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 8ff5094..9630948 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: @@ -72,4 +73,5 @@ jobs: -Dsonar.sources=. -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 + } } From 795846d07660b1cc60956ad8f61f4cf81814a360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Wed, 11 Feb 2026 19:35:25 +0100 Subject: [PATCH 8/8] fix: Dsonar.sources in pipeline --- .github/workflows/pr-check.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 9630948..17638aa 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -70,7 +70,8 @@ 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