diff --git a/src/main/java/com/back/domain/user/controller/AuthController.java b/src/main/java/com/back/domain/user/controller/AuthController.java new file mode 100644 index 00000000..97571950 --- /dev/null +++ b/src/main/java/com/back/domain/user/controller/AuthController.java @@ -0,0 +1,47 @@ +package com.back.domain.user.controller; + +import com.back.domain.user.dto.UserRegisterRequest; +import com.back.domain.user.dto.UserResponse; +import com.back.domain.user.service.UserService; +import com.back.global.common.dto.RsData; +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.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + private final UserService userService; + + @PostMapping("/register") + @Operation( + summary = "회원가입", + description = "신규 사용자를 등록합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "회원가입 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 / 비밀번호 정책 위반"), + @ApiResponse(responseCode = "409", description = "중복된 아이디/이메일/닉네임"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity> register( + @Valid @RequestBody UserRegisterRequest request + ) { + UserResponse response = userService.register(request); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(RsData.success( + "회원가입이 성공적으로 완료되었습니다. 이메일 인증을 완료해주세요.", + response + )); + } +} diff --git a/src/main/java/com/back/domain/user/dto/UserRegisterRequest.java b/src/main/java/com/back/domain/user/dto/UserRegisterRequest.java new file mode 100644 index 00000000..c3852920 --- /dev/null +++ b/src/main/java/com/back/domain/user/dto/UserRegisterRequest.java @@ -0,0 +1,19 @@ +package com.back.domain.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +/** + * 사용자 회원 가입 요청을 나타내는 DTO + * + * @param username 사용자의 로그인 id + * @param email 사용자의 이메일 주소 + * @param password 사용자의 비밀번호 + * @param nickname 사용자의 별명 + */ +public record UserRegisterRequest( + @NotBlank String username, + @NotBlank @Email String email, + @NotBlank String password, + @NotBlank String nickname +) {} diff --git a/src/main/java/com/back/domain/user/dto/UserResponse.java b/src/main/java/com/back/domain/user/dto/UserResponse.java new file mode 100644 index 00000000..e4bd09d6 --- /dev/null +++ b/src/main/java/com/back/domain/user/dto/UserResponse.java @@ -0,0 +1,41 @@ +package com.back.domain.user.dto; + +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; + +import java.time.LocalDateTime; + +/** + * 사용자 응답을 나타내는 DTO + * + * @param userId 사용자의 고유 ID + * @param username 사용자의 로그인 id + * @param email 사용자의 이메일 주소 + * @param nickname 사용자의 별명 + * @param role 사용자의 역할 (예: USER, ADMIN) + * @param status 사용자의 상태 (예: ACTIVE, PENDING) + * @param createdAt 사용자가 생성된 날짜 및 시간 + */ +public record UserResponse( + Long userId, + String username, + String email, + String nickname, + Role role, + UserStatus status, + LocalDateTime createdAt +) { + public static UserResponse from(User user, UserProfile profile) { + return new UserResponse( + user.getId(), + user.getUsername(), + user.getEmail(), + profile.getNickname(), + user.getRole(), + user.getUserStatus(), + user.getCreatedAt() + ); + } +} 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 554aa564..831fb3a3 100644 --- a/src/main/java/com/back/domain/user/entity/User.java +++ b/src/main/java/com/back/domain/user/entity/User.java @@ -36,8 +36,8 @@ public class User extends BaseEntity { @Enumerated(EnumType.STRING) private UserStatus userStatus; - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - private List userProfiles = new ArrayList<>(); + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private UserProfile userProfile; @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List userTokens = new ArrayList<>(); @@ -81,21 +81,42 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List fileAttachments = new ArrayList<>(); + // -------------------- 생성자 -------------------- + public User(String username, String email, String password, Role role, UserStatus userStatus) { + this.username = username; + this.email = email; + this.password = password; + this.role = role; + this.userStatus = userStatus; + } + + // -------------------- 정적 팩토리 메서드 -------------------- + // 일반 사용자 생성 + public static User createUser(String username, String email, String password) { + return new User(username, email, password, Role.USER, UserStatus.PENDING); + } + + // 관리자 사용자 생성 + public static User createAdmin(String username, String email, String password) { + return new User(username, email, password, Role.ADMIN, UserStatus.ACTIVE); + } + + // -------------------- 연관관계 메서드 -------------------- + public void setUserProfile(UserProfile profile) { + this.userProfile = profile; + profile.setUser(this); + } + // -------------------- 헬퍼 메서드 -------------------- // 현재 사용자의 닉네임 조회 public String getNickname() { - return userProfiles.stream() - .findFirst() - .map(UserProfile::getNickname) - .filter(nickname -> nickname != null && !nickname.trim().isEmpty()) - .orElse(this.username); + return userProfile != null && userProfile.getNickname() != null && !userProfile.getNickname().trim().isEmpty() + ? userProfile.getNickname() + : this.username; } // 현재 사용자의 프로필 이미지 URL 조회 public String getProfileImageUrl() { - return userProfiles.stream() - .findFirst() - .map(UserProfile::getProfileImageUrl) - .orElse(null); + return userProfile != null ? userProfile.getProfileImageUrl() : null; } } diff --git a/src/main/java/com/back/domain/user/entity/UserProfile.java b/src/main/java/com/back/domain/user/entity/UserProfile.java index 32494985..ab7a3e89 100644 --- a/src/main/java/com/back/domain/user/entity/UserProfile.java +++ b/src/main/java/com/back/domain/user/entity/UserProfile.java @@ -2,16 +2,20 @@ import com.back.global.entity.BaseEntity; import jakarta.persistence.*; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import java.time.LocalDateTime; @Entity @Getter +@Setter @NoArgsConstructor +@AllArgsConstructor public class UserProfile extends BaseEntity { - @ManyToOne(fetch = FetchType.LAZY) + @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; diff --git a/src/main/java/com/back/domain/user/repository/UserProfileRepository.java b/src/main/java/com/back/domain/user/repository/UserProfileRepository.java new file mode 100644 index 00000000..344d0fdf --- /dev/null +++ b/src/main/java/com/back/domain/user/repository/UserProfileRepository.java @@ -0,0 +1,10 @@ +package com.back.domain.user.repository; + +import com.back.domain.user.entity.UserProfile; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserProfileRepository extends JpaRepository { + boolean existsByNickname(String nickname); +} diff --git a/src/main/java/com/back/domain/user/repository/UserRepository.java b/src/main/java/com/back/domain/user/repository/UserRepository.java index 89086370..9af303b7 100644 --- a/src/main/java/com/back/domain/user/repository/UserRepository.java +++ b/src/main/java/com/back/domain/user/repository/UserRepository.java @@ -4,6 +4,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface UserRepository extends JpaRepository { + boolean existsByUsername(String username); + boolean existsByEmail(String email); + Optional findByUsername(String username); } \ No newline at end of file diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java new file mode 100644 index 00000000..43967dbd --- /dev/null +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -0,0 +1,93 @@ +package com.back.domain.user.service; + +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.repository.UserProfileRepository; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class UserService { + private final UserRepository userRepository; + private final UserProfileRepository userProfileRepository; + private final PasswordEncoder passwordEncoder; + + /** + * 회원가입 서비스 + * 1. 중복 검사 (username, email, nickname) + * 2. 비밀번호 정책 검증 + * 3. User + UserProfile 생성 및 연관관계 설정 + * 4. 저장 후 UserResponse 변환 + */ + public UserResponse register(UserRegisterRequest request) { + + // 중복 검사 (username, email, nickname) + validateDuplicate(request); + + // 비밀번호 정책 검증 + validatePasswordPolicy(request.password()); + + // User 엔티티 생성 (기본 Role.USER, Status.PENDING) + User user = User.createUser( + request.username(), + request.email(), + passwordEncoder.encode(request.password()) + ); + + // UserProfile 엔티티 생성 + UserProfile profile = new UserProfile( + user, + request.nickname(), + null, + null, + null, + 0 + ); + + // 연관관계 설정 + user.setUserProfile(profile); + + // 저장 (cascade로 Profile도 함께 저장됨) + User saved = userRepository.save(user); + + // UserResponse 변환 및 반환 + return UserResponse.from(saved, profile); + } + + /** + * 회원가입 시 중복 검증 + * - username, email, nickname + */ + private void validateDuplicate(UserRegisterRequest request) { + if (userRepository.existsByUsername(request.username())) { + throw new CustomException(ErrorCode.USERNAME_DUPLICATED); + } + if (userRepository.existsByEmail(request.email())) { + throw new CustomException(ErrorCode.EMAIL_DUPLICATED); + } + if (userProfileRepository.existsByNickname(request.nickname())) { + throw new CustomException(ErrorCode.NICKNAME_DUPLICATED); + } + } + + /** + * 비밀번호 정책 검증 + * - 최소 8자 이상 + * - 숫자 및 특수문자 반드시 포함 + */ + private void validatePasswordPolicy(String password) { + String regex = "^(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$"; + if (!password.matches(regex)) { + throw new CustomException(ErrorCode.INVALID_PASSWORD); + } + } +} diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index 2e723757..9b1021a7 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -10,6 +10,10 @@ public enum ErrorCode { // ======================== 사용자 관련 ======================== USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_001", "존재하지 않는 사용자입니다."), + USERNAME_DUPLICATED(HttpStatus.CONFLICT, "USER_002", "이미 사용 중인 아이디입니다."), + EMAIL_DUPLICATED(HttpStatus.CONFLICT, "USER_003", "이미 사용 중인 이메일입니다."), + NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "USER_004", "이미 사용 중인 닉네임입니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "USER_005", "비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다."), // ======================== 스터디룸 관련 ======================== ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "ROOM_001", "존재하지 않는 방입니다."), diff --git a/src/main/java/com/back/global/exception/GlobalExceptionHandler.java b/src/main/java/com/back/global/exception/GlobalExceptionHandler.java index 6ad58702..a2e7b82a 100644 --- a/src/main/java/com/back/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/back/global/exception/GlobalExceptionHandler.java @@ -1,7 +1,9 @@ package com.back.global.exception; import com.back.global.common.dto.RsData; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -17,4 +19,11 @@ public ResponseEntity> handleCustomException( .status(errorCode.getStatus()) .body(RsData.fail(errorCode)); } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException ex) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(RsData.fail(ErrorCode.BAD_REQUEST)); + } } diff --git a/src/test/java/com/back/domain/user/controller/AuthControllerTest.java b/src/test/java/com/back/domain/user/controller/AuthControllerTest.java new file mode 100644 index 00000000..b3f7b95b --- /dev/null +++ b/src/test/java/com/back/domain/user/controller/AuthControllerTest.java @@ -0,0 +1,192 @@ +package com.back.domain.user.controller; + +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 org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class AuthControllerTest { + + @Autowired + private MockMvc mvc; + + @Autowired + private UserRepository userRepository; + + @Test + @DisplayName("정상 회원가입 → 201 Created") + void register_success() throws Exception { + // given: 정상적인 회원가입 요청 JSON + String body = """ + { + "username": "testuser", + "email": "test@example.com", + "password": "P@ssw0rd!", + "nickname": "홍길동" + } + """; + + // when: 회원가입 API 호출 + ResultActions resultActions = mvc.perform( + post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + ).andDo(print()); + + // then: 응답 값과 DB 저장값 검증 + resultActions + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.username").value("testuser")) + .andExpect(jsonPath("$.data.email").value("test@example.com")) + .andExpect(jsonPath("$.data.nickname").value("홍길동")); + + // DB에서 저장된 User 상태 검증 + User saved = userRepository.findByUsername("testuser").orElseThrow(); + assertThat(saved.getUserStatus()).isEqualTo(UserStatus.PENDING); + } + + @Test + @DisplayName("중복 username → 409 Conflict") + void register_duplicateUsername() throws Exception { + // given: 이미 존재하는 username을 가진 User 저장 + User existing = User.createUser("dupuser", "dup@example.com", "password123!"); + existing.setUserProfile(new UserProfile(existing, "dupnick", null, null, null, 0)); + userRepository.save(existing); + + // 동일 username으로 회원가입 요청 + String body = """ + { + "username": "dupuser", + "email": "other@example.com", + "password": "P@ssw0rd!", + "nickname": "다른닉네임" + } + """; + + // when & then: 409 Conflict 응답 및 에러 코드 확인 + mvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andDo(print()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("USER_002")); + } + + @Test + @DisplayName("중복 email → 409 Conflict") + void register_duplicateEmail() throws Exception { + // given: 이미 존재하는 email을 가진 User 저장 + User existing = User.createUser("user1", "dup@example.com", "password123!"); + existing.setUserProfile(new UserProfile(existing, "nick1", null, null, null, 0)); + userRepository.save(existing); + + // 동일 email로 회원가입 요청 + String body = """ + { + "username": "otheruser", + "email": "dup@example.com", + "password": "P@ssw0rd!", + "nickname": "다른닉네임" + } + """; + + // when & then: 409 Conflict 응답 및 에러 코드 확인 + mvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andDo(print()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("USER_003")); + } + + @Test + @DisplayName("중복 nickname → 409 Conflict") + void register_duplicateNickname() throws Exception { + // given: 이미 존재하는 nickname을 가진 User 저장 + User existing = User.createUser("user2", "user2@example.com", "password123!"); + existing.setUserProfile(new UserProfile(existing, "dupnick", null, null, null, 0)); + userRepository.save(existing); + + // 동일 nickname으로 회원가입 요청 + String body = """ + { + "username": "newuser", + "email": "new@example.com", + "password": "P@ssw0rd!", + "nickname": "dupnick" + } + """; + + // when & then: 409 Conflict 응답 및 에러 코드 확인 + mvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andDo(print()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("USER_004")); + } + + @Test + @DisplayName("비밀번호 정책 위반 → 400 Bad Request") + void register_invalidPassword() throws Exception { + // given: 숫자/특수문자 포함 안 된 약한 비밀번호 + String body = """ + { + "username": "weakpw", + "email": "weak@example.com", + "password": "password", + "nickname": "닉네임" + } + """; + + // when & then: 400 Bad Request 응답 및 에러 코드 확인 + mvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("USER_005")); + } + + @Test + @DisplayName("잘못된 요청값 (필수 필드 누락) → 400 Bad Request") + void register_invalidRequest_missingField() throws Exception { + // given: 필수 값 누락 (username, password, nickname 비어있음 / email 형식 잘못됨) + String body = """ + { + "username": "", + "email": "invalid", + "password": "", + "nickname": "" + } + """; + + // when & then: 400 Bad Request 응답 및 공통 에러 코드 확인 + mvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_400")); + } +} \ 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 new file mode 100644 index 00000000..df03b468 --- /dev/null +++ b/src/test/java/com/back/domain/user/service/UserServiceTest.java @@ -0,0 +1,158 @@ +package com.back.domain.user.service; + +import com.back.domain.user.dto.UserRegisterRequest; +import com.back.domain.user.dto.UserResponse; +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 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.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class UserServiceTest { + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserProfileRepository userProfileRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Test + @DisplayName("정상 회원가입 성공") + void register_success() { + // given: 정상적인 회원가입 요청 생성 + UserRegisterRequest request = new UserRegisterRequest( + "testuser", "test@example.com", "P@ssw0rd!", "홍길동" + ); + + // when: 회원가입 실행 + UserResponse response = userService.register(request); + + // then: 반환된 값 검증 + assertThat(response.username()).isEqualTo("testuser"); + assertThat(response.email()).isEqualTo("test@example.com"); + assertThat(response.nickname()).isEqualTo("홍길동"); + assertThat(response.status()).isEqualTo(UserStatus.PENDING); + + // 비밀번호 인코딩 검증 + String encoded = userRepository.findById(response.userId()).get().getPassword(); + assertThat(passwordEncoder.matches("P@ssw0rd!", encoded)).isTrue(); + + // UserProfile도 함께 생성되었는지 확인 + assertThat(userProfileRepository.existsByNickname("홍길동")).isTrue(); + } + + @Test + @DisplayName("중복된 username이면 예외 발생") + void register_duplicateUsername() { + // given: 동일 username으로 첫 번째 가입 + userService.register(new UserRegisterRequest( + "dupuser", "dup@example.com", "P@ssw0rd!", "닉네임" + )); + + // when & then: 같은 username으로 가입 시 예외 발생 + assertThatThrownBy(() -> + userService.register(new UserRegisterRequest( + "dupuser", "other@example.com", "P@ssw0rd!", "다른닉네임" + )) + ).isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USERNAME_DUPLICATED.getMessage()); + } + + @Test + @DisplayName("중복된 email이면 예외 발생") + void register_duplicateEmail() { + // given: 동일 email로 첫 번째 가입 + userService.register(new UserRegisterRequest( + "user1", "dup@example.com", "P@ssw0rd!", "닉네임" + )); + + // when & then: 같은 email로 가입 시 예외 발생 + assertThatThrownBy(() -> + userService.register(new UserRegisterRequest( + "user2", "dup@example.com", "P@ssw0rd!", "다른닉네임" + )) + ).isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.EMAIL_DUPLICATED.getMessage()); + } + + @Test + @DisplayName("중복된 nickname이면 예외 발생") + void register_duplicateNickname() { + // given: 동일 nickname으로 첫 번째 가입 + userService.register(new UserRegisterRequest( + "user1", "user1@example.com", "P@ssw0rd!", "dupnick" + )); + + // when & then: 같은 nickname으로 가입 시 예외 발생 + assertThatThrownBy(() -> + userService.register(new UserRegisterRequest( + "user2", "user2@example.com", "P@ssw0rd!", "dupnick" + )) + ).isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.NICKNAME_DUPLICATED.getMessage()); + } + + @Test + @DisplayName("비밀번호 정책 위반(숫자/특수문자 없음) → 예외 발생") + void register_invalidPassword_noNumberOrSpecial() { + // given: 숫자, 특수문자 없는 비밀번호 + UserRegisterRequest request = new UserRegisterRequest( + "user1", "user1@example.com", "abcdefgh", "닉네임" + ); + + // when & then: 정책 위반으로 예외 발생 + assertThatThrownBy(() -> userService.register(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_PASSWORD.getMessage()); + } + + @Test + @DisplayName("비밀번호 정책 위반(길이 7자) → 예외 발생") + void register_invalidPassword_tooShort() { + // given: 7자리 비밀번호 (정책상 8자 이상 필요) + UserRegisterRequest request = new UserRegisterRequest( + "user2", "user2@example.com", "Abc12!", "닉네임" + ); + + // when & then: 정책 위반으로 예외 발생 + assertThatThrownBy(() -> userService.register(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_PASSWORD.getMessage()); + } + + @Test + @DisplayName("비밀번호 정책 통과(정상 8자 이상, 숫자/특수문자 포함) → 성공") + void register_validPassword() { + // given: 정책을 만족하는 정상 비밀번호 + UserRegisterRequest request = new UserRegisterRequest( + "user3", "user3@example.com", "Abcd123!", "닉네임" + ); + + // when: 회원가입 실행 + UserResponse response = userService.register(request); + + // then: username과 비밀번호 인코딩 검증 + assertThat(response.username()).isEqualTo("user3"); + assertThat(passwordEncoder.matches("Abcd123!", + userRepository.findById(response.userId()).get().getPassword())).isTrue(); + } +} \ No newline at end of file