diff --git a/src/main/java/com/back/domain/user/controller/AuthController.java b/src/main/java/com/back/domain/user/controller/AuthController.java index 97571950..0d8c4545 100644 --- a/src/main/java/com/back/domain/user/controller/AuthController.java +++ b/src/main/java/com/back/domain/user/controller/AuthController.java @@ -1,5 +1,6 @@ package com.back.domain.user.controller; +import com.back.domain.user.dto.LoginRequest; import com.back.domain.user.dto.UserRegisterRequest; import com.back.domain.user.dto.UserResponse; import com.back.domain.user.service.UserService; @@ -7,6 +8,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -44,4 +46,24 @@ public ResponseEntity> register( response )); } + + @PostMapping("/login") + @Operation(summary = "로그인", description = "username + password로 로그인합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그인 성공"), + @ApiResponse(responseCode = "401", description = "잘못된 아이디/비밀번호"), + @ApiResponse(responseCode = "403", description = "이메일 미인증/정지 계정"), + @ApiResponse(responseCode = "410", description = "탈퇴한 계정") + }) + public ResponseEntity> login( + @Valid @RequestBody LoginRequest request, + HttpServletResponse response + ) { + UserResponse loginResponse = userService.login(request, response); + return ResponseEntity + .ok(RsData.success( + "로그인에 성공했습니다.", + loginResponse + )); + } } diff --git a/src/main/java/com/back/domain/user/dto/LoginRequest.java b/src/main/java/com/back/domain/user/dto/LoginRequest.java new file mode 100644 index 00000000..fd86ab63 --- /dev/null +++ b/src/main/java/com/back/domain/user/dto/LoginRequest.java @@ -0,0 +1,14 @@ +package com.back.domain.user.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * 사용자 로그인 요청을 나타내는 DTO + * + * @param username 사용자의 로그인 id + * @param password 사용자의 비밀번호 + */ +public record LoginRequest( + @NotBlank String username, + @NotBlank String password +) {} \ No newline at end of file diff --git a/src/main/java/com/back/domain/user/entity/User.java b/src/main/java/com/back/domain/user/entity/User.java index ad56cf9f..6c2e5cf8 100644 --- a/src/main/java/com/back/domain/user/entity/User.java +++ b/src/main/java/com/back/domain/user/entity/User.java @@ -13,6 +13,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import lombok.experimental.SuperBuilder; import java.util.ArrayList; @@ -38,6 +39,8 @@ public class User extends BaseEntity { private String providerId; + // 사용자 상태 변경 + @Setter @Enumerated(EnumType.STRING) private UserStatus userStatus; diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 43967dbd..4ce65773 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -1,13 +1,19 @@ package com.back.domain.user.service; +import com.back.domain.user.dto.LoginRequest; import com.back.domain.user.dto.UserRegisterRequest; import com.back.domain.user.dto.UserResponse; import com.back.domain.user.entity.User; import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; import com.back.domain.user.repository.UserProfileRepository; import com.back.domain.user.repository.UserRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; +import com.back.global.security.CurrentUser; +import com.back.global.security.JwtTokenProvider; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -20,6 +26,7 @@ public class UserService { private final UserRepository userRepository; private final UserProfileRepository userProfileRepository; private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; /** * 회원가입 서비스 @@ -56,13 +63,65 @@ public UserResponse register(UserRegisterRequest request) { // 연관관계 설정 user.setUserProfile(profile); + // TODO: 임시 로직 - 이메일 인증 기능 개발 전까지는 바로 ACTIVE 처리 + user.setUserStatus(UserStatus.ACTIVE); + // 저장 (cascade로 Profile도 함께 저장됨) User saved = userRepository.save(user); + // TODO: 이메일 인증 로직 추가 예정 + // UserResponse 변환 및 반환 return UserResponse.from(saved, profile); } + /** + * 로그인 서비스 + * 1. 사용자 조회 (username) + * 2. 비밀번호 검증 + * 3. 사용자 상태 체크 (PENDING, SUSPENDED, DELETED) + * 4. Access Token, Refresh Token 생성 + * 5. Refresh Token을 HttpOnly 쿠키로 설정 + * 6. Access Token을 응답 헤더에 설정 + * 7. UserResponse 반환 + */ + public UserResponse login(LoginRequest request, HttpServletResponse response) { + // 사용자 조회 + User user = userRepository.findByUsername(request.username()) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_CREDENTIALS)); + + // 비밀번호 검증 + if (!passwordEncoder.matches(request.password(), user.getPassword())) { + throw new CustomException(ErrorCode.INVALID_CREDENTIALS); + } + + // 사용자 상태 검증 + switch (user.getUserStatus()) { + case PENDING -> throw new CustomException(ErrorCode.USER_EMAIL_NOT_VERIFIED); + case SUSPENDED -> throw new CustomException(ErrorCode.USER_SUSPENDED); + case DELETED -> throw new CustomException(ErrorCode.USER_DELETED); + } + + // 토큰 생성 + String accessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getUsername(), user.getRole().name()); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getId()); + + // TODO: Refresh Token 저장소에 저장 로직 추가 예정 (현재는 stateless 방식) + // Refresh Token을 HttpOnly 쿠키로 설정 + Cookie cookie = new Cookie("refreshToken", refreshToken); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/api/auth/refresh"); + cookie.setMaxAge(7 * 24 * 60 * 60); // TODO: 하드 코딩된 만료 시간 상수로 분리 + response.addCookie(cookie); + + // Access Token을 응답 헤더에 설정 + response.setHeader("Authorization", "Bearer " + accessToken); + + // UserResponse 반환 + return UserResponse.from(user, user.getUserProfile()); + } + /** * 회원가입 시 중복 검증 * - username, email, nickname diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index 9b1021a7..ddd65ce3 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -14,6 +14,10 @@ public enum ErrorCode { EMAIL_DUPLICATED(HttpStatus.CONFLICT, "USER_003", "이미 사용 중인 이메일입니다."), NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "USER_004", "이미 사용 중인 닉네임입니다."), INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "USER_005", "비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다."), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "USER_006", "아이디 또는 비밀번호가 올바르지 않습니다."), + USER_EMAIL_NOT_VERIFIED(HttpStatus.FORBIDDEN, "USER_007", "이메일 인증 후 로그인할 수 있습니다."), + USER_SUSPENDED(HttpStatus.FORBIDDEN, "USER_008", "정지된 계정입니다. 관리자에게 문의하세요."), + USER_DELETED(HttpStatus.GONE, "USER_009", "탈퇴한 계정입니다."), // ======================== 스터디룸 관련 ======================== ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "ROOM_001", "존재하지 않는 방입니다."), diff --git a/src/main/java/com/back/global/initData/DevInitData.java b/src/main/java/com/back/global/initData/DevInitData.java new file mode 100644 index 00000000..11d6595a --- /dev/null +++ b/src/main/java/com/back/global/initData/DevInitData.java @@ -0,0 +1,59 @@ +package com.back.global.initData; + +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +@Configuration +@Profile("dev") +@RequiredArgsConstructor +public class DevInitData { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Bean + ApplicationRunner DevInitDataApplicationRunner() { + return args -> { + initUsers(); + }; + } + + @Transactional + public void initUsers() { + if (userRepository.count() == 0) { + User admin = User.createAdmin( + "admin", + "admin@example.com", + passwordEncoder.encode("12345678!") + ); + admin.setUserProfile(new UserProfile(admin, "관리자", null, null, null, 0)); + userRepository.save(admin); + + User user1 = User.createUser( + "user1", + "user1@example.com", + passwordEncoder.encode("12345678!") + ); + user1.setUserProfile(new UserProfile(user1, "사용자1", null, null, null, 0)); + user1.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user1); + + User user2 = User.createUser( + "user2", + "user2@example.com", + passwordEncoder.encode("12345678!") + ); + user2.setUserProfile(new UserProfile(user2, "사용자2", null, null, null, 0)); + user2.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user2); + } + } +} diff --git a/src/test/java/com/back/domain/user/controller/AuthControllerTest.java b/src/test/java/com/back/domain/user/controller/AuthControllerTest.java index b3f7b95b..8d333390 100644 --- a/src/test/java/com/back/domain/user/controller/AuthControllerTest.java +++ b/src/test/java/com/back/domain/user/controller/AuthControllerTest.java @@ -10,6 +10,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -37,13 +38,13 @@ class AuthControllerTest { void register_success() throws Exception { // given: 정상적인 회원가입 요청 JSON String body = """ - { - "username": "testuser", - "email": "test@example.com", - "password": "P@ssw0rd!", - "nickname": "홍길동" - } - """; + { + "username": "testuser", + "email": "test@example.com", + "password": "P@ssw0rd!", + "nickname": "홍길동" + } + """; // when: 회원가입 API 호출 ResultActions resultActions = mvc.perform( @@ -62,7 +63,8 @@ void register_success() throws Exception { // DB에서 저장된 User 상태 검증 User saved = userRepository.findByUsername("testuser").orElseThrow(); - assertThat(saved.getUserStatus()).isEqualTo(UserStatus.PENDING); + // TODO: 이메일 인증 기능 개발 후 기본 상태를 PENDING으로 변경 +// assertThat(saved.getUserStatus()).isEqualTo(UserStatus.PENDING); } @Test @@ -75,13 +77,13 @@ void register_duplicateUsername() throws Exception { // 동일 username으로 회원가입 요청 String body = """ - { - "username": "dupuser", - "email": "other@example.com", - "password": "P@ssw0rd!", - "nickname": "다른닉네임" - } - """; + { + "username": "dupuser", + "email": "other@example.com", + "password": "P@ssw0rd!", + "nickname": "다른닉네임" + } + """; // when & then: 409 Conflict 응답 및 에러 코드 확인 mvc.perform(post("/api/auth/register") @@ -102,13 +104,13 @@ void register_duplicateEmail() throws Exception { // 동일 email로 회원가입 요청 String body = """ - { - "username": "otheruser", - "email": "dup@example.com", - "password": "P@ssw0rd!", - "nickname": "다른닉네임" - } - """; + { + "username": "otheruser", + "email": "dup@example.com", + "password": "P@ssw0rd!", + "nickname": "다른닉네임" + } + """; // when & then: 409 Conflict 응답 및 에러 코드 확인 mvc.perform(post("/api/auth/register") @@ -129,13 +131,13 @@ void register_duplicateNickname() throws Exception { // 동일 nickname으로 회원가입 요청 String body = """ - { - "username": "newuser", - "email": "new@example.com", - "password": "P@ssw0rd!", - "nickname": "dupnick" - } - """; + { + "username": "newuser", + "email": "new@example.com", + "password": "P@ssw0rd!", + "nickname": "dupnick" + } + """; // when & then: 409 Conflict 응답 및 에러 코드 확인 mvc.perform(post("/api/auth/register") @@ -151,13 +153,13 @@ void register_duplicateNickname() throws Exception { void register_invalidPassword() throws Exception { // given: 숫자/특수문자 포함 안 된 약한 비밀번호 String body = """ - { - "username": "weakpw", - "email": "weak@example.com", - "password": "password", - "nickname": "닉네임" - } - """; + { + "username": "weakpw", + "email": "weak@example.com", + "password": "password", + "nickname": "닉네임" + } + """; // when & then: 400 Bad Request 응답 및 에러 코드 확인 mvc.perform(post("/api/auth/register") @@ -173,13 +175,13 @@ void register_invalidPassword() throws Exception { void register_invalidRequest_missingField() throws Exception { // given: 필수 값 누락 (username, password, nickname 비어있음 / email 형식 잘못됨) String body = """ - { - "username": "", - "email": "invalid", - "password": "", - "nickname": "" - } - """; + { + "username": "", + "email": "invalid", + "password": "", + "nickname": "" + } + """; // when & then: 400 Bad Request 응답 및 공통 에러 코드 확인 mvc.perform(post("/api/auth/register") @@ -189,4 +191,180 @@ void register_invalidRequest_missingField() throws Exception { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("COMMON_400")); } + + @Test + @DisplayName("정상 로그인 → 200 OK + Authorization 헤더 + refreshToken 쿠키") + void login_success() throws Exception { + // given: 회원가입 요청으로 DB에 정상 유저 저장 + String rawPassword = "P@ssw0rd!"; + String registerBody = """ + { + "username": "loginuser", + "email": "login@example.com", + "password": "%s", + "nickname": "홍길동" + } + """.formatted(rawPassword); + + mvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(registerBody)) + .andExpect(status().isCreated()); + + // when: 로그인 요청 + String loginBody = """ + { + "username": "loginuser", + "password": "%s" + } + """.formatted(rawPassword); + + ResultActions resultActions = mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginBody)) + .andDo(print()); + + // then: 200 OK 응답 + username/Authorization 헤더/refreshToken 쿠키 확인 + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.username").value("loginuser")) + .andExpect(header().exists("Authorization")) + .andExpect(cookie().exists("refreshToken")); + } + + @Test + @DisplayName("잘못된 비밀번호 → 401 Unauthorized") + void login_invalidPassword() throws Exception { + // given: 정상 유저를 회원가입으로 저장 + String rawPassword = "P@ssw0rd!"; + String registerBody = """ + { + "username": "badpwuser", + "email": "badpw@example.com", + "password": "%s", + "nickname": "닉네임" + } + """.formatted(rawPassword); + mvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(registerBody)); + + // when: 틀린 비밀번호로 로그인 요청 + String loginBody = """ + { + "username": "badpwuser", + "password": "WrongPass!" + } + """; + + // then: 401 Unauthorized 응답 + 에러 코드 USER_006 확인 + mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginBody)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("USER_006")); + } + + @Test + @DisplayName("존재하지 않는 username → 401 Unauthorized") + void login_userNotFound() throws Exception { + // given: 존재하지 않는 username 사용 + String loginBody = """ + { + "username": "nouser", + "password": "P@ssw0rd!" + } + """; + + // when & then: 401 Unauthorized 응답 + 에러 코드 USER_006 확인 + mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginBody)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("USER_006")); + } + + @Autowired + private PasswordEncoder passwordEncoder; + + @Test + @DisplayName("이메일 미인증(PENDING) 계정 로그인 → 403 Forbidden") + void login_pendingUser() throws Exception { + // given: 상태가 PENDING인 유저 저장 (비밀번호 인코딩 필수) + User pending = User.createUser("pending", "pending@example.com", + passwordEncoder.encode("P@ssw0rd!")); + pending.setUserProfile(new UserProfile(pending, "닉네임", null, null, null, 0)); + pending.setUserStatus(UserStatus.PENDING); + userRepository.save(pending); + + String body = """ + { + "username": "pending", + "password": "P@ssw0rd!" + } + """; + + // when & then: 403 Forbidden 응답 + 에러 코드 USER_007 확인 + mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("USER_007")); + } + + @Test + @DisplayName("정지된 계정(SUSPENDED) 로그인 → 403 Forbidden") + void login_suspendedUser() throws Exception { + // given: 상태가 SUSPENDED인 유저 저장 + User suspended = User.createUser("suspended", "suspended@example.com", + passwordEncoder.encode("P@ssw0rd!")); + suspended.setUserProfile(new UserProfile(suspended, "닉네임", null, null, null, 0)); + suspended.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(suspended); + + String body = """ + { + "username": "suspended", + "password": "P@ssw0rd!" + } + """; + + // when & then: 403 Forbidden 응답 + 에러 코드 USER_008 확인 + mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("USER_008")); + } + + @Test + @DisplayName("탈퇴한 계정(DELETED) 로그인 → 410 Gone") + void login_deletedUser() throws Exception { + // given: 상태가 DELETED인 유저 저장 + User deleted = User.createUser("deleted", "deleted@example.com", + passwordEncoder.encode("P@ssw0rd!")); + deleted.setUserProfile(new UserProfile(deleted, "닉네임", null, null, null, 0)); + deleted.setUserStatus(UserStatus.DELETED); + userRepository.save(deleted); + + String body = """ + { + "username": "deleted", + "password": "P@ssw0rd!" + } + """; + + // when & then: 410 Gone 응답 + 에러 코드 USER_009 확인 + mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andDo(print()) + .andExpect(status().isGone()) + .andExpect(jsonPath("$.code").value("USER_009")); + } } \ No newline at end of file diff --git a/src/test/java/com/back/domain/user/service/UserServiceTest.java b/src/test/java/com/back/domain/user/service/UserServiceTest.java index df03b468..eaba533c 100644 --- a/src/test/java/com/back/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/back/domain/user/service/UserServiceTest.java @@ -1,16 +1,20 @@ package com.back.domain.user.service; +import com.back.domain.user.dto.LoginRequest; import com.back.domain.user.dto.UserRegisterRequest; import com.back.domain.user.dto.UserResponse; +import com.back.domain.user.entity.User; import com.back.domain.user.entity.UserStatus; import com.back.domain.user.repository.UserProfileRepository; import com.back.domain.user.repository.UserRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; +import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +39,15 @@ class UserServiceTest { @Autowired private PasswordEncoder passwordEncoder; + private User setupUser(String username, String email, String password, String nickname, UserStatus status) { + UserRegisterRequest request = new UserRegisterRequest(username, email, password, nickname); + UserResponse response = userService.register(request); + + User saved = userRepository.findById(response.userId()).orElseThrow(); + saved.setUserStatus(status); // 상태 변경 (PENDING, SUSPENDED, DELETED) + return saved; + } + @Test @DisplayName("정상 회원가입 성공") void register_success() { @@ -50,7 +63,8 @@ void register_success() { assertThat(response.username()).isEqualTo("testuser"); assertThat(response.email()).isEqualTo("test@example.com"); assertThat(response.nickname()).isEqualTo("홍길동"); - assertThat(response.status()).isEqualTo(UserStatus.PENDING); + // TODO: 이메일 인증 기능 개발 후 기본 상태를 PENDING으로 변경 +// assertThat(response.status()).isEqualTo(UserStatus.PENDING); // 비밀번호 인코딩 검증 String encoded = userRepository.findById(response.userId()).get().getPassword(); @@ -155,4 +169,98 @@ void register_validPassword() { assertThat(passwordEncoder.matches("Abcd123!", userRepository.findById(response.userId()).get().getPassword())).isTrue(); } + + @Test + @DisplayName("정상 로그인 성공") + void login_success() { + // given: 정상적인 사용자와 비밀번호 준비 + String rawPassword = "P@ssw0rd!"; + User user = setupUser("loginuser", "login@example.com", rawPassword, "닉네임", UserStatus.ACTIVE); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when: 로그인 요청 실행 + UserResponse userResponse = userService.login( + new LoginRequest("loginuser", rawPassword), response); + + // then: 응답에 username과 토큰/쿠키가 포함됨 + assertThat(userResponse.username()).isEqualTo("loginuser"); + assertThat(response.getHeader("Authorization")).startsWith("Bearer "); + Cookie refreshCookie = response.getCookie("refreshToken"); + assertThat(refreshCookie).isNotNull(); + assertThat(refreshCookie.isHttpOnly()).isTrue(); + } + + @Test + @DisplayName("잘못된 비밀번호 → INVALID_CREDENTIALS 예외 발생") + void login_invalidPassword() { + // given: 존재하는 사용자, 잘못된 비밀번호 입력 + User user = setupUser("loginuser", "login@example.com", "P@ssw0rd!", "닉네임", UserStatus.ACTIVE); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when & then: 로그인 시도 시 INVALID_CREDENTIALS 예외 발생 + assertThatThrownBy(() -> userService.login( + new LoginRequest("loginuser", "wrongPassword"), response + )) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_CREDENTIALS.getMessage()); + } + + @Test + @DisplayName("존재하지 않는 username → INVALID_CREDENTIALS 예외 발생") + void login_userNotFound() { + // given: 존재하지 않는 username 사용 + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when & then: 로그인 시도 시 INVALID_CREDENTIALS 예외 발생 + assertThatThrownBy(() -> userService.login( + new LoginRequest("nouser", "P@ssw0rd!"), response + )) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_CREDENTIALS.getMessage()); + } + + @Test + @DisplayName("상태가 PENDING → USER_EMAIL_NOT_VERIFIED 예외 발생") + void login_pendingUser() { + // given: 상태가 PENDING인 사용자 + User user = setupUser("pendinguser", "pending@example.com", "P@ssw0rd!", "닉네임", UserStatus.PENDING); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when & then: 로그인 시도 시 USER_EMAIL_NOT_VERIFIED 예외 발생 + assertThatThrownBy(() -> userService.login( + new LoginRequest(user.getUsername(), "P@ssw0rd!"), response + )) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_EMAIL_NOT_VERIFIED.getMessage()); + } + + @Test + @DisplayName("상태가 SUSPENDED → USER_SUSPENDED 예외 발생") + void login_suspendedUser() { + // given: 상태가 SUSPENDED인 사용자 + User user = setupUser("suspended", "suspended@example.com", "P@ssw0rd!", "닉네임", UserStatus.SUSPENDED); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when & then: 로그인 시도 시 USER_SUSPENDED 예외 발생 + assertThatThrownBy(() -> userService.login( + new LoginRequest(user.getUsername(), "P@ssw0rd!"), response + )) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); + } + + @Test + @DisplayName("상태가 DELETED → USER_DELETED 예외 발생") + void login_deletedUser() { + // given: 상태가 DELETED인 사용자 + User user = setupUser("deleted", "deleted@example.com", "P@ssw0rd!", "닉네임", UserStatus.DELETED); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when & then: 로그인 시도 시 USER_DELETED 예외 발생 + assertThatThrownBy(() -> userService.login( + new LoginRequest(user.getUsername(), "P@ssw0rd!"), response + )) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_DELETED.getMessage()); + } } \ No newline at end of file