diff --git a/backend/src/main/java/org/sejongisc/backend/auth/controller/OauthLoginController.java b/backend/src/main/java/org/sejongisc/backend/auth/controller/OauthLoginController.java index ff34db6b..e9174a7b 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/controller/OauthLoginController.java +++ b/backend/src/main/java/org/sejongisc/backend/auth/controller/OauthLoginController.java @@ -1,5 +1,6 @@ package org.sejongisc.backend.auth.controller; +import io.jsonwebtoken.JwtException; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -32,6 +33,7 @@ public class OauthLoginController { private final JwtProvider jwtProvider; private final OauthStateService oauthStateService; + @Value("${google.client.id}") private String googleClientId; @@ -171,4 +173,42 @@ public ResponseEntity OauthLogin(@PathVariable("provider") String .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .body(response); } + + @PostMapping("/logout") + public ResponseEntity logout(@RequestHeader(value = "Authorization", required = false) String authorizationHeader) { + // 1️⃣ 헤더 유효성 검사 + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + return ResponseEntity.badRequest() + .body(Map.of("message", "잘못된 Authorization 헤더 형식입니다.")); + } + + String token = authorizationHeader.substring(7); + + // 2️⃣ 예외 처리 및 멱등성 보장 + try { + loginService.logout(token); + } catch (JwtException | IllegalArgumentException e) { + // 이미 만료되었거나 잘못된 토큰이라도 200 OK로 응답 (멱등성 보장) + log.warn("Invalid or expired JWT during logout: {}", e.getMessage()); + } catch (Exception e) { + log.error("Unexpected error during logout", e); + // 내부 예외는 500으로 보내지 않고 안전하게 처리 + } + + // 3️⃣ Refresh Token 쿠키 삭제 + ResponseCookie deleteCookie = ResponseCookie.from("refresh", "") + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .maxAge(0) + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, deleteCookie.toString()) + .body(Map.of("message", "로그아웃 성공")); + } + + } + diff --git a/backend/src/main/java/org/sejongisc/backend/auth/entity/RefreshToken.java b/backend/src/main/java/org/sejongisc/backend/auth/entity/RefreshToken.java new file mode 100644 index 00000000..346f29b7 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/auth/entity/RefreshToken.java @@ -0,0 +1,21 @@ +package org.sejongisc.backend.auth.entity; + +import jakarta.persistence. *; +import lombok.*; +import java.util.UUID; + +@Entity +@Table(name = "refresh_token") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class RefreshToken { + + @Id + @Column(name = "user_id", nullable = false, columnDefinition = "uuid") + private UUID userid; + + @Column(nullable = false, length = 500) + private String token; +} diff --git a/backend/src/main/java/org/sejongisc/backend/auth/repository/RefreshTokenRepository.java b/backend/src/main/java/org/sejongisc/backend/auth/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..c27f24f4 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,14 @@ +package org.sejongisc.backend.auth.repository; + +import org.sejongisc.backend.auth.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByUserid(UUID userId); + + void deleteByUserId(UUID userId); +} diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/LoginService.java b/backend/src/main/java/org/sejongisc/backend/auth/service/LoginService.java index 89e4d07f..3f6b10cc 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/LoginService.java +++ b/backend/src/main/java/org/sejongisc/backend/auth/service/LoginService.java @@ -1,8 +1,11 @@ package org.sejongisc.backend.auth.service; +import jakarta.servlet.http.HttpServletRequest; import org.sejongisc.backend.auth.dto.LoginRequest; import org.sejongisc.backend.auth.dto.LoginResponse; public interface LoginService { LoginResponse login(LoginRequest request); + + void logout(String accessToken); } diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/LoginServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/auth/service/LoginServiceImpl.java index 5bbc0360..4c142afe 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/LoginServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/auth/service/LoginServiceImpl.java @@ -1,7 +1,11 @@ package org.sejongisc.backend.auth.service; +import jakarta.servlet.http.HttpServletRequest; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.auth.repository.RefreshTokenRepository; +import org.sejongisc.backend.common.auth.jwt.JwtParser; import org.sejongisc.backend.common.auth.jwt.JwtProvider; import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.sejongisc.backend.common.exception.CustomException; @@ -15,6 +19,10 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.sql.Ref; +import java.util.UUID; + +@Slf4j @Service @RequiredArgsConstructor public class LoginServiceImpl implements LoginService { @@ -22,6 +30,8 @@ public class LoginServiceImpl implements LoginService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtProvider jwtProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtParser jwtParser; @Override @Transactional @@ -46,4 +56,12 @@ public LoginResponse login(LoginRequest request) { .point(user.getPoint()) .build(); } + + @Override + @Transactional + public void logout(String accessToken) { + UUID userId = jwtParser.getUserIdFromToken(accessToken); + refreshTokenRepository.deleteByUserId(userId); + log.info("로그아웃 완료: userId={}", userId); + } } diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/Oauth2Service.java b/backend/src/main/java/org/sejongisc/backend/auth/service/Oauth2Service.java index 8f486cd6..f7b5ed5a 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/Oauth2Service.java +++ b/backend/src/main/java/org/sejongisc/backend/auth/service/Oauth2Service.java @@ -7,4 +7,6 @@ public interface Oauth2Service { TToken getAccessToken(String code); TUserInfo getUserInfo(String accessToken); + + } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index 3dfb57e0..86bf4f51 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -50,6 +50,7 @@ public enum ErrorCode { DUPLICATE_EMAIL(HttpStatus.CONFLICT, "이미 가입된 이메일입니다."), DUPLICATE_PHONE(HttpStatus.CONFLICT, "이미 사용 중인 전화번호입니다."), DUPLICATE_USER(HttpStatus.CONFLICT, "이미 가입된 사용자입니다."), + INVALID_INPUT(HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다."), // BETTING diff --git a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java index 6fb2a8bb..ca273157 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java +++ b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java @@ -7,12 +7,16 @@ import org.sejongisc.backend.auth.dto.SignupRequest; import org.sejongisc.backend.auth.dto.SignupResponse; import org.sejongisc.backend.user.dto.UserInfoResponse; +import org.sejongisc.backend.user.dto.UserUpdateRequest; import org.sejongisc.backend.user.service.UserService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.Map; +import java.util.UUID; + @RestController @RequiredArgsConstructor @RequestMapping("/user") @@ -43,4 +47,24 @@ public ResponseEntity getUserInfo(@AuthenticationPrincipal Cus return ResponseEntity.ok(response); } + + @PatchMapping("/{userId}") + public ResponseEntity updateUser( + @PathVariable UUID userId, + @RequestBody @Valid UserUpdateRequest request, + @AuthenticationPrincipal CustomUserDetails authenticatedUser + ) { + if(authenticatedUser == null){ + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "인증 정보가 필요합니다.")); + } + + // 본인 허용 + if (!authenticatedUser.getUserId().equals(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("message", "본인의 정보만 수정할 수 있습니다.")); + } + + userService.updateUser(userId, request); + return ResponseEntity.ok("회원 정보가 수정되었습니다."); + } } diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/UserUpdateRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/UserUpdateRequest.java new file mode 100644 index 00000000..f63b1ddc --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/UserUpdateRequest.java @@ -0,0 +1,12 @@ +package org.sejongisc.backend.user.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UserUpdateRequest { + private String name; + private String phoneNumber; + private String password; // 변경 시에만 받기 +} diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java index e816e745..0a1773f3 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java +++ b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java @@ -2,11 +2,16 @@ import org.sejongisc.backend.auth.dto.SignupRequest; import org.sejongisc.backend.auth.dto.SignupResponse; +import org.sejongisc.backend.user.dto.UserUpdateRequest; import org.sejongisc.backend.user.entity.User; import org.sejongisc.backend.auth.oauth.OauthUserInfo; +import java.util.UUID; + public interface UserService { SignupResponse signUp(SignupRequest dto); User findOrCreateUser(OauthUserInfo oauthInfo); + + void updateUser(UUID userId, UserUpdateRequest request); } diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java index bdd352e5..770cd080 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java @@ -1,6 +1,7 @@ package org.sejongisc.backend.user.service; -import jakarta.transaction.Transactional; + +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.common.exception.CustomException; @@ -9,6 +10,7 @@ import org.sejongisc.backend.user.dao.UserRepository; import org.sejongisc.backend.auth.dto.SignupRequest; import org.sejongisc.backend.auth.dto.SignupResponse; +import org.sejongisc.backend.user.dto.UserUpdateRequest; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; import org.sejongisc.backend.auth.entity.UserOauthAccount; @@ -17,6 +19,8 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.util.UUID; + @Slf4j @Service @RequiredArgsConstructor @@ -93,4 +97,32 @@ public User findOrCreateUser(OauthUserInfo oauthInfo) { }); } + @Override + @Transactional + public void updateUser(UUID userId, UserUpdateRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 이름 업데이트 + if (request.getName() != null && !request.getName().trim().isEmpty()) { + user.setName(request.getName().trim()); + } + + // 전화번호 업데이트 + if (request.getPhoneNumber() != null && !request.getPhoneNumber().trim().isEmpty()) { + user.setPhoneNumber(request.getPhoneNumber().trim()); + } + + if (request.getPassword() != null) { + String trimmedPassword = request.getPassword().trim(); + if (trimmedPassword.isEmpty()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + user.setPasswordHash(passwordEncoder.encode(trimmedPassword)); + } + + log.info("회원 정보가 수정되었습니다. userId={}", userId); + userRepository.save(user); + } + } diff --git a/backend/src/test/java/org/sejongisc/backend/auth/controller/OauthLoginControllerTest.java b/backend/src/test/java/org/sejongisc/backend/auth/controller/OauthLoginControllerTest.java index 49a09840..03a3b10c 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/controller/OauthLoginControllerTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/controller/OauthLoginControllerTest.java @@ -27,9 +27,13 @@ import java.util.Map; import java.util.UUID; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.anyString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -76,6 +80,8 @@ void setUp() { .build(); } + + // ✅ 일반 로그인 테스트 @Test @DisplayName("POST /auth/login - 로그인 성공 시 200 OK") @@ -232,4 +238,98 @@ void githubLogin_success() throws Exception { .andExpect(jsonPath("$.accessToken").value("mock-jwt-token")) .andExpect(jsonPath("$.name").value("깃허브유저")); } + + @DisplayName("startOauthLogin - 각 provider별 정상 응답 확인") + @Test + void startOauthLogin_success() throws Exception { + // given + when(oauthStateService.generateAndSaveState(any(HttpSession.class))).thenReturn("state123"); + + // when & then + mockMvc.perform(get("/auth/oauth/google/init")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("accounts.google.com"))); + + mockMvc.perform(get("/auth/oauth/kakao/init")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("kauth.kakao.com"))); + + mockMvc.perform(get("/auth/oauth/github/init")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("github.com"))); + } + + @DisplayName("startOauthLogin - 존재하지 않는 provider 요청 시 IllegalArgumentException 발생") + @Test + void startOauthLogin_invalidProvider() throws Exception { + when(oauthStateService.generateAndSaveState(any(HttpSession.class))).thenReturn("state123"); + + mockMvc.perform(get("/auth/oauth/unknown/init")) + .andExpect(status().is5xxServerError()); + } + + @DisplayName("OauthLogin - 저장된 state와 불일치 시 401 반환") + @Test + void oauthLogin_invalidState() throws Exception { + when(oauthStateService.getStateFromSession(any(HttpSession.class))).thenReturn("expected_state"); + + mockMvc.perform(post("/auth/login/google") + .param("code", "valid_code") + .param("state", "wrong_state")) + .andExpect(status().isUnauthorized()); + } + + @DisplayName("OauthLogin - provider가 잘못된 경우 500 반환") + @Test + void oauthLogin_invalidProvider() throws Exception { + when(oauthStateService.getStateFromSession(any(HttpSession.class))).thenReturn("valid_state"); + + mockMvc.perform(post("/auth/login/unknown") + .param("code", "valid_code") + .param("state", "valid_state")) + .andExpect(status().is5xxServerError()); + } + + @Test + @DisplayName("POST /auth/login/{provider} - 지원하지 않는 provider 요청 시 500 반환") + void oauthLogin_unknownProvider() throws Exception { + when(oauthStateService.getStateFromSession(any(HttpSession.class))).thenReturn("valid_state"); + + mockMvc.perform(post("/auth/login/UNSUPPORTED") + .param("code", "valid_code") + .param("state", "valid_state")) + .andExpect(status().is5xxServerError()) + .andExpect(result -> + assertTrue(result.getResolvedException() instanceof IllegalArgumentException)) + .andExpect(result -> + assertTrue(result.getResolvedException().getMessage().contains("Unknown provider"))); + } + + + @Test + @DisplayName("POST /auth/logout - 정상 로그아웃 시 200 반환") + void logout_success() throws Exception { + String token = "fake.jwt.token"; + String bearerToken = "Bearer " + token; + + mockMvc.perform(post("/auth/logout") + .header("Authorization", bearerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("로그아웃 성공")); + + verify(loginService, times(1)).logout(token); + } + + @Test + @DisplayName("POST /auth/logout - Authorization 헤더가 없으면 400 반환") + void logout_missingHeader() throws Exception { + mockMvc.perform(post("/auth/logout")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("잘못된 Authorization 헤더 형식입니다.")); + + + verify(loginService, times(0)).logout(anyString()); + } + + } diff --git a/backend/src/test/java/org/sejongisc/backend/auth/service/LoginServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/auth/service/LoginServiceImplTest.java index ff5c8ced..20c5cca2 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/service/LoginServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/service/LoginServiceImplTest.java @@ -6,6 +6,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.sejongisc.backend.auth.repository.RefreshTokenRepository; +import org.sejongisc.backend.common.auth.jwt.JwtParser; import org.sejongisc.backend.common.auth.jwt.JwtProvider; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; @@ -21,12 +23,12 @@ import java.util.Optional; import java.util.UUID; +import static org.mockito.Mockito.*; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.when; + @ExtendWith(MockitoExtension.class) class LoginServiceImplTest { @@ -40,7 +42,15 @@ class LoginServiceImplTest { @Mock private JwtProvider jwtProvider; - @InjectMocks private LoginServiceImpl loginService; + @Mock + private JwtParser jwtParser; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + + @InjectMocks + private LoginServiceImpl loginService; @Test @DisplayName("정상 로그인 시 LoginResponse 반환") @@ -129,4 +139,51 @@ void login_wrongPassword() { assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED); } + + @Test + @DisplayName("비밀번호가 null인 경우 UNAUTHORIZED 예외 발생") + void login_nullPassword() { + // given + UUID userId = UUID.randomUUID(); + User user = User.builder() + .userId(userId) + .email("test@example.com") + .name("홍길동") + .passwordHash(null) // <-- 핵심 + .role(Role.TEAM_MEMBER) + .point(0) + .build(); + + LoginRequest request = new LoginRequest(); + request.setEmail("test@example.com"); + request.setPassword("somePassword"); + + given(userRepository.findUserByEmail("test@example.com")) + .willReturn(Optional.of(user)); + + // when & then + CustomException exception = assertThrows(CustomException.class, + () -> loginService.login(request)); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED); + } + + + @Test + @DisplayName("로그아웃 시 RefreshTokenRepository.deleteByUserId()가 호출된다") + void logout_success() { + + UUID userId = UUID.randomUUID(); + String fakeToken = "fake.jwt.token"; + + when(jwtParser.getUserIdFromToken(fakeToken)).thenReturn(userId); + + + loginService.logout(fakeToken); + + + verify(jwtParser, times(1)).getUserIdFromToken(fakeToken); + verify(refreshTokenRepository, times(1)).deleteByUserId(userId); + } + } diff --git a/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceImplTest.java index 78166b58..4e81ae2e 100644 --- a/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceImplTest.java @@ -1,8 +1,7 @@ package org.sejongisc.backend.user.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -23,6 +22,7 @@ import org.sejongisc.backend.auth.dto.SignupRequest; import org.sejongisc.backend.auth.dto.SignupResponse; import org.sejongisc.backend.auth.entity.AuthProvider; +import org.sejongisc.backend.user.dto.UserUpdateRequest; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; import org.sejongisc.backend.auth.entity.UserOauthAccount; @@ -270,4 +270,88 @@ void findOrCreateUser_newUser() { verify(oauthAccountRepository).save(any(UserOauthAccount.class)); } + @Test + @DisplayName("회원정보 수정 성공: 이름, 전화번호, 비밀번호 변경") + void updateUser_success() { + // given + UUID userId = UUID.randomUUID(); + User existingUser = User.builder() + .userId(userId) + .name("기존이름") + .phoneNumber("010-1111-1111") + .passwordHash("OLD_HASH") + .role(Role.TEAM_MEMBER) + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(existingUser)); + when(passwordEncoder.encode("newPassword123")).thenReturn("NEW_HASH"); + + // 수정 요청 DTO + var request = new org.sejongisc.backend.user.dto.UserUpdateRequest(); + request.setName("새이름"); + request.setPhoneNumber("010-2222-3333"); + request.setPassword("newPassword123"); + + // when + userService.updateUser(userId, request); + + // then + assertAll( + () -> assertThat(existingUser.getName()).isEqualTo("새이름"), + () -> assertThat(existingUser.getPhoneNumber()).isEqualTo("010-2222-3333"), + () -> assertThat(existingUser.getPasswordHash()).isEqualTo("NEW_HASH") + ); + + verify(userRepository).findById(userId); + verify(passwordEncoder).encode("newPassword123"); + verify(userRepository).save(existingUser); + } + + @Test + @DisplayName("회원정보 수정 실패: 존재하지 않는 사용자일 경우 예외 발생") + void updateUser_notFound_throws() { + // given + UUID nonExistingId = UUID.randomUUID(); + UserUpdateRequest request = new UserUpdateRequest(); + + CustomException exception = assertThrows(CustomException.class, + () -> userService.updateUser(nonExistingId, request)); + + assertEquals(ErrorCode.USER_NOT_FOUND, exception.getErrorCode()); + } + + @Test + @DisplayName("회원정보 수정: 모든 필드가 null이면 기존 정보 그대로 유지") + void updateUser_allFieldsNull_noChanges() { + // given + UUID userId = UUID.randomUUID(); + User existingUser = User.builder() + .userId(userId) + .name("원래이름") + .phoneNumber("010-1111-1111") + .passwordHash("OLD_HASH") + .role(Role.TEAM_MEMBER) + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(existingUser)); + + // 모든 필드가 null인 요청 DTO + var request = new org.sejongisc.backend.user.dto.UserUpdateRequest(); + + // when + userService.updateUser(userId, request); + + // then + assertAll( + () -> assertThat(existingUser.getName()).isEqualTo("원래이름"), + () -> assertThat(existingUser.getPhoneNumber()).isEqualTo("010-1111-1111"), + () -> assertThat(existingUser.getPasswordHash()).isEqualTo("OLD_HASH") + ); + + verify(userRepository).findById(userId); + verify(userRepository).save(existingUser); + verifyNoInteractions(passwordEncoder); // 비밀번호 인코더 안 씀 + } + + }