diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..cb54a44b 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.security:spring-security-crypto") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/AuthUserPrincipal.java b/apps/commerce-api/src/main/java/com/loopers/application/user/AuthUserPrincipal.java new file mode 100644 index 00000000..6400332b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/AuthUserPrincipal.java @@ -0,0 +1,23 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.UserModel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 인증된 사용자의 식별 정보. 컨트롤러에서 "현재 사용자"를 식별하는 용도로 사용한다. + */ +@Getter +@AllArgsConstructor +public class AuthUserPrincipal { + + private final Long id; + private final String loginId; + + public static AuthUserPrincipal from(UserModel user) { + return new AuthUserPrincipal( + user.getId(), + user.getLoginId() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/SignUpCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/SignUpCommand.java new file mode 100644 index 00000000..fb71d21e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/SignUpCommand.java @@ -0,0 +1,16 @@ +package com.loopers.application.user; + +import java.time.LocalDate; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class SignUpCommand { + + private final String loginId; + private final String loginPw; + private final LocalDate birthDate; + private final String name; + private final String email; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 00000000..e6b486f1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,35 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import com.loopers.interfaces.user.ChangePasswordRequest; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@AllArgsConstructor +public class UserFacade { + + private final UserService userService; + + public UserInfo signUp(SignUpCommand command) { + UserModel userModel = userService.createUser( + command.getLoginId(), + command.getLoginPw(), + command.getBirthDate(), + command.getName(), + command.getEmail() + ); + + return UserInfo.from(userModel); + } + + public UserInfo getMyInfo(Long userId) { + UserModel user = userService.findById(userId); + return UserInfo.from(user); + } + + public void changePassword(Long userId, ChangePasswordRequest request) { + userService.changePassword(userId, request.currentPassword(), request.newPassword()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 00000000..09c90259 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,17 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.UserModel; +import java.time.LocalDate; + +public record UserInfo(Long id, String loginId, String name, String email, LocalDate birthDate) { + + public static UserInfo from(UserModel userModel) { + return new UserInfo( + userModel.getId(), + userModel.getLoginId(), + userModel.getName(), + userModel.getEmail(), + userModel.getBirthDate() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java new file mode 100644 index 00000000..d42feb17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java new file mode 100644 index 00000000..c55b3d20 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,23 @@ +package com.loopers.config; + +import com.loopers.interfaces.api.AuthUserArgumentResolver; +import com.loopers.interfaces.api.CredentialsHeadersArgumentResolver; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final CredentialsHeadersArgumentResolver credentialsHeadersArgumentResolver; + private final AuthUserArgumentResolver authUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(credentialsHeadersArgumentResolver); + resolvers.add(authUserArgumentResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java new file mode 100644 index 00000000..b19ca04b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -0,0 +1,80 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDate; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Entity +@Table(name = "users") +@NoArgsConstructor +@Getter +public class UserModel extends BaseEntity { + + @Id + @GeneratedValue + private Long id; + + @Comment("아이디") + @Column(name = "login_id", nullable = false) + private String loginId; + + @Comment("비밀번호") + @Column(name = "password", nullable = false) + private String password; + + @Comment("생년월일") + @Column(name = "birth", nullable = false) + private LocalDate birthDate; + + @Comment("이름") + @Column(name = "name", nullable = false) + private String name; + + @Comment("이메일") + @Column(name = "email", nullable = false) + private String email; + + private UserModel(String loginId, String password, LocalDate birthDate, String name, String email) { + this.loginId = loginId; + this.password = password; + this.birthDate = birthDate; + this.name = name; + this.email = email; + } + + @Override + protected void guard() { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); + } + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); + } + if (birthDate == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + } + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다."); + } + } + + public static UserModel create(String loginId, String encodedPassword, LocalDate birthDate, String name, String email) { + return new UserModel(loginId, encodedPassword, birthDate, name, email); + } + + public void changePassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 00000000..cbdcf135 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + + UserModel save(UserModel userModel); + + Optional findById(Long id); + + Optional findByLoginId(String loginId); + + Boolean existsByEmail(String email); + + Boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 00000000..7f65e772 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,75 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.transaction.Transactional; +import java.time.LocalDate; +import lombok.AllArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public UserModel createUser(String loginId, String rawPassword, LocalDate birthDate, String name, String email) { + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 사용 중인 아이디입니다."); + } + if (userRepository.existsByEmail(email)) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 가입된 이메일입니다."); + } + validatePasswordNotContainsBirthDate(rawPassword, birthDate); + + String encodedPassword = passwordEncoder.encode(rawPassword); + UserModel user = UserModel.create(loginId, encodedPassword, birthDate, name, email); + return userRepository.save(user); + } + + public UserModel authenticate(String loginId, String rawPassword) { + UserModel user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "로그인 정보가 올바르지 않습니다.")); + if (!passwordEncoder.matches(rawPassword, user.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "로그인 정보가 올바르지 않습니다."); + } + return user; + } + + public Boolean existsByEmail(String email) { + return userRepository.existsByEmail(email); + } + + public UserModel findById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + } + + @Transactional + public void changePassword(Long userId, String currentPassword, String newPassword) { + UserModel user = findById(userId); + + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "기존 비밀번호가 일치하지 않습니다."); + } + + if (passwordEncoder.matches(newPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 기존 비밀번호와 달라야 합니다."); + } + + validatePasswordNotContainsBirthDate(newPassword, user.getBirthDate()); + + String newEncodedPassword = passwordEncoder.encode(newPassword); + user.changePassword(newEncodedPassword); + } + + private void validatePasswordNotContainsBirthDate(String password, LocalDate birthDate) { + String birthStr = birthDate.toString().replace("-", ""); + if (password.contains(birthStr)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java new file mode 100644 index 00000000..e1b9b594 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.user.UserModel; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserJpaRepository extends JpaRepository { + + Boolean existsByEmail(String email); + + Boolean existsByLoginId(String loginId); + + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java new file mode 100644 index 00000000..19a304e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public UserModel save(UserModel userModel) { + return userJpaRepository.save(userModel); + } + + @Override + public java.util.Optional findById(Long id) { + return userJpaRepository.findById(id); + } + + @Override + public java.util.Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(loginId); + } + + @Override + public Boolean existsByEmail(String email) { + return userJpaRepository.existsByEmail(email); + } + + @Override + public Boolean existsByLoginId(String loginId) { + return userJpaRepository.existsByLoginId(loginId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c..2cee9a9b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -5,9 +5,14 @@ import com.fasterxml.jackson.databind.exc.MismatchedInputException; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +51,31 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + FieldError fieldError = e.getBindingResult().getFieldErrors().get(0); + String fieldName = fieldError.getField(); + String message = fieldError.getDefaultMessage(); + String errorMessage = String.format("필드 '%s': %s", fieldName, message); + return failureResponse(ErrorType.BAD_REQUEST, errorMessage); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) { + String headerName = e.getHeaderName(); + String message = String.format("필수 요청 헤더 '%s'가 누락되었습니다.", headerName); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(ConstraintViolationException e) { + String message = e.getConstraintViolations().stream() + .findFirst() + .map(ConstraintViolation::getMessage) + .orElse("요청 값이 올바르지 않습니다."); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUser.java new file mode 100644 index 00000000..e8eab205 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUser.java @@ -0,0 +1,16 @@ +package com.loopers.interfaces.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 인증된 사용자 인자를 주입받을 때 사용. + * {@link AuthUserArgumentResolver}가 헤더(X-Loopers-LoginId, X-Loopers-LoginPw)로 인증 후 + * {@link com.loopers.application.AuthUserPrincipal}을 주입한다. + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthUser { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java new file mode 100644 index 00000000..61b17d31 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java @@ -0,0 +1,51 @@ +package com.loopers.interfaces.api; + +import com.loopers.application.user.AuthUserPrincipal; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { + + private final UserService userService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthUser.class) + && parameter.getParameterType().equals(AuthUserPrincipal.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + if (request == null) { + throw new IllegalStateException("HttpServletRequest not available"); + } + + String loginId = request.getHeader(LoopersHeaders.X_LOOPERS_LOGIN_ID); + String loginPw = request.getHeader(LoopersHeaders.X_LOOPERS_LOGIN_PW); + + if (loginId == null || loginId.isBlank() || loginPw == null || loginPw.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 누락되었습니다."); + } + + UserModel user = userService.authenticate(loginId, loginPw); + return AuthUserPrincipal.from(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/CredentialsHeaders.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/CredentialsHeaders.java new file mode 100644 index 00000000..e29333f8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/CredentialsHeaders.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 로그인 ID/비밀번호를 담는 헤더 값 DTO. + * Argument Resolver를 통해 요청 헤더에서 바인딩·검증 후 주입된다. + */ +@Getter +@AllArgsConstructor +public class CredentialsHeaders { + + @NotBlank(message = "로그인 ID는 필수입니다.") + @Pattern(regexp = "^[A-Za-z0-9]+$", message = "로그인 ID는 영문 대소문자, 숫자만 사용 가능합니다.") + private String loginId; + + @NotBlank(message = "비밀번호는 필수입니다.") + @Size(min = 8, max = 16, message = "8~16자로 입력해주세요.") + @Pattern( + regexp = "^[A-Za-z0-9\\p{P}\\p{S}]+$", + message = "영문 대소문자, 숫자, 특수문자만 사용 가능합니다." + ) + private String loginPw; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/CredentialsHeadersArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/CredentialsHeadersArgumentResolver.java new file mode 100644 index 00000000..d024ae74 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/CredentialsHeadersArgumentResolver.java @@ -0,0 +1,49 @@ +package com.loopers.interfaces.api; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Validator; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class CredentialsHeadersArgumentResolver implements HandlerMethodArgumentResolver { + + private final Validator validator; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType() == CredentialsHeaders.class; + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + if (request == null) { + throw new IllegalStateException("HttpServletRequest not available"); + } + + String loginId = request.getHeader(LoopersHeaders.X_LOOPERS_LOGIN_ID); + String loginPw = request.getHeader(LoopersHeaders.X_LOOPERS_LOGIN_PW); + + CredentialsHeaders headers = new CredentialsHeaders(loginId, loginPw); + Set> violations = validator.validate(headers); + if (!violations.isEmpty()) { + throw new ConstraintViolationException(violations); + } + return headers; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/LoopersHeaders.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/LoopersHeaders.java new file mode 100644 index 00000000..e0ed063d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/LoopersHeaders.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api; + +/** + * 유저 정보가 필요한 요청에서 사용하는 헤더 이름. + *
    + *
  • X-Loopers-LoginId : 로그인 ID
  • + *
  • X-Loopers-LoginPw : 비밀번호
  • + *
+ */ +public final class LoopersHeaders { + + public static final String X_LOOPERS_LOGIN_ID = "X-Loopers-LoginId"; + public static final String X_LOOPERS_LOGIN_PW = "X-Loopers-LoginPw"; + + private LoopersHeaders() { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/ChangePasswordRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/ChangePasswordRequest.java new file mode 100644 index 00000000..4f145db3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/ChangePasswordRequest.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.user; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record ChangePasswordRequest( + + @NotBlank(message = "기존 비밀번호는 필수입니다.") + String currentPassword, + + @NotBlank(message = "새 비밀번호는 필수입니다.") + @Size(min = 8, max = 16, message = "비밀번호는 8~16자로 입력해주세요.") + @Pattern( + regexp = "^[A-Za-z0-9\\p{P}\\p{S}]+$", + message = "영문 대소문자, 숫자, 특수문자만 사용 가능합니다." + ) + String newPassword +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserDto.java new file mode 100644 index 00000000..28e7da86 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserDto.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.user; + +import com.loopers.application.user.UserInfo; +import com.loopers.support.MaskingUtils; +import java.time.LocalDate; + +public class UserDto { + + public record SignUpResponse(Long id) { + public static SignUpResponse from(UserInfo userInfo) { + return new SignUpResponse( + userInfo.id() + ); + } + } + + public record MyInfoResponse(String loginId, String name, LocalDate birthDate, String email) { + + public static MyInfoResponse from(UserInfo userInfo) { + return new MyInfoResponse( + userInfo.loginId(), + MaskingUtils.maskLastCharacter(userInfo.name()), + userInfo.birthDate(), + userInfo.email() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UsersController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UsersController.java new file mode 100644 index 00000000..9125b817 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UsersController.java @@ -0,0 +1,58 @@ +package com.loopers.interfaces.user; + +import com.loopers.application.user.AuthUserPrincipal; +import com.loopers.application.user.SignUpCommand; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.AuthUser; +import com.loopers.interfaces.api.CredentialsHeaders; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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("/users") +@AllArgsConstructor +public class UsersController { + + private final UserFacade userFacade; + + @PostMapping + public ApiResponse signUp( + CredentialsHeaders credentialsHeaders, + @Valid @RequestBody UsersSignUpRequestDto requestDto + ) { + SignUpCommand command = new SignUpCommand( + credentialsHeaders.getLoginId(), + credentialsHeaders.getLoginPw(), + requestDto.getBirthDate(), + requestDto.getName(), + requestDto.getEmail() + ); + + UserInfo userInfo = userFacade.signUp(command); + + return ApiResponse.success(UserDto.SignUpResponse.from(userInfo)); + } + + @GetMapping("/me") + public ApiResponse getMe(@AuthUser AuthUserPrincipal authUser) { + UserInfo userInfo = userFacade.getMyInfo(authUser.getId()); + return ApiResponse.success(UserDto.MyInfoResponse.from(userInfo)); + } + + @PatchMapping("/me/password") + public ApiResponse changePassword( + @AuthUser AuthUserPrincipal authUser, + @Valid @RequestBody ChangePasswordRequest request + ) { + userFacade.changePassword(authUser.getId(), request); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UsersSignUpRequestDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UsersSignUpRequestDto.java new file mode 100644 index 00000000..742d560a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UsersSignUpRequestDto.java @@ -0,0 +1,34 @@ +package com.loopers.interfaces.user; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Past; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UsersSignUpRequestDto { + + @NotNull(message = "생년월일은 필수입니다.") + @Past(message = "생년월일은 과거 날짜여야 합니다.") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate birthDate; + + @NotBlank(message = "이름은 필수입니다.") + @Size(min = 2, max = 10, message = "이름은 2자 이상 30자 이하여야 합니다.") + @Pattern( + regexp = "^[가-힣a-zA-Z\\s]+$", + message = "이름은 한글, 영문, 공백만 입력 가능합니다." + ) + private String name; + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + private String email; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/MaskingUtils.java b/apps/commerce-api/src/main/java/com/loopers/support/MaskingUtils.java new file mode 100644 index 00000000..cd8b0fa5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/MaskingUtils.java @@ -0,0 +1,13 @@ +package com.loopers.support; + +public final class MaskingUtils { + + private MaskingUtils() {} + + public static String maskLastCharacter(String value) { + if (value == null || value.isEmpty()) { + return value; + } + return value.substring(0, value.length() - 1) + "*"; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efb..b8f29045 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -11,7 +11,11 @@ public enum ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + + NOT_INCLUDE_BIRTH_IN_PASSWORD(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "비밀번호에 생년월일을 포함할 수 없습니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증이 필요합니다.") + ; private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/UserFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/UserFacadeTest.java new file mode 100644 index 00000000..9a4a0400 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/UserFacadeTest.java @@ -0,0 +1,51 @@ +package com.loopers.application; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.loopers.application.user.SignUpCommand; +import com.loopers.application.user.UserFacade; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import java.time.LocalDate; +import org.junit.jupiter.api.DisplayName; +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; + +@ExtendWith(MockitoExtension.class) +public class UserFacadeTest { + + @InjectMocks + private UserFacade userFacade; + + @Mock + private UserService userService; + + @Test + @DisplayName("회원가입 성공 테스트") + void success_signup() { + + String rawPw = "securePassword!@"; + LocalDate birthDate = LocalDate.of(1995, 1, 1); + SignUpCommand signUpCommand = new SignUpCommand( + "user123", + rawPw, + birthDate, + "kim", + "yk@naver.com" + ); + + UserModel savedUser = UserModel.create("user123", "encoded_hash", birthDate, "kim", "yk@naver.com"); + given(userService.createUser(eq("user123"), eq(rawPw), eq(birthDate), eq("kim"), eq("yk@naver.com"))) + .willReturn(savedUser); + + userFacade.signUp(signUpCommand); + + verify(userService, times(1)).createUser(eq("user123"), eq(rawPw), eq(birthDate), eq("kim"), eq("yk@naver.com")); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java new file mode 100644 index 00000000..66e70a92 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -0,0 +1,70 @@ +package com.loopers.domain.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import java.time.LocalDate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class UserModelTest { + + @DisplayName("UserModel 생성 테스트") + @Test + void success_create_userModel() { + String loginId = "user123"; + String encodedPw = "encoded_hash"; + LocalDate birthDate = LocalDate.of(1991, 12, 3); + String name = "김용권"; + String email = "yk@google.com"; + + UserModel user = UserModel.create(loginId, encodedPw, birthDate, name, email); + + assertThat(user.getLoginId()).isEqualTo(loginId); + assertThat(user.getPassword()).isEqualTo(encodedPw); + assertThat(user.getBirthDate()).isEqualTo(birthDate); + assertThat(user.getName()).isEqualTo(name); + assertThat(user.getEmail()).isEqualTo(email); + } + + @DisplayName("loginId가 null이면 guard에서 예외가 발생한다.") + @Test + void guard_fail_when_loginId_is_null() { + UserModel user = UserModel.create(null, "encoded_hash", LocalDate.of(1991, 12, 3), "김용권", "yk@google.com"); + + assertThatThrownBy(user::guard).isInstanceOf(CoreException.class); + } + + @DisplayName("password가 null이면 guard에서 예외가 발생한다.") + @Test + void guard_fail_when_password_is_null() { + UserModel user = UserModel.create("user123", null, LocalDate.of(1991, 12, 3), "김용권", "yk@google.com"); + + assertThatThrownBy(user::guard).isInstanceOf(CoreException.class); + } + + @DisplayName("birthDate가 null이면 guard에서 예외가 발생한다.") + @Test + void guard_fail_when_birthDate_is_null() { + UserModel user = UserModel.create("user123", "encoded_hash", null, "김용권", "yk@google.com"); + + assertThatThrownBy(user::guard).isInstanceOf(CoreException.class); + } + + @DisplayName("name이 null이면 guard에서 예외가 발생한다.") + @Test + void guard_fail_when_name_is_null() { + UserModel user = UserModel.create("user123", "encoded_hash", LocalDate.of(1991, 12, 3), null, "yk@google.com"); + + assertThatThrownBy(user::guard).isInstanceOf(CoreException.class); + } + + @DisplayName("email이 null이면 guard에서 예외가 발생한다.") + @Test + void guard_fail_when_email_is_null() { + UserModel user = UserModel.create("user123", "encoded_hash", LocalDate.of(1991, 12, 3), "김용권", null); + + assertThatThrownBy(user::guard).isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java new file mode 100644 index 00000000..2476123f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -0,0 +1,183 @@ +package com.loopers.domain.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import com.loopers.support.error.CoreException; +import java.time.LocalDate; +import java.util.Optional; +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.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @InjectMocks + private UserService userService; + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @DisplayName("회원 가입") + @Nested + class CreateUser { + + @Test + @DisplayName("비밀번호에 생년월일이 포함되면 예외가 발생한다") + void fail_when_password_contains_birthDate() { + // arrange + String loginId = "user123"; + String rawPassword = "Pass19911203!"; + LocalDate birthDate = LocalDate.of(1991, 12, 3); + String name = "김용권"; + String email = "yk@google.com"; + + // act & assert + assertThatThrownBy(() -> userService.createUser(loginId, rawPassword, birthDate, name, email)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("비밀번호에 생년월일을 포함할 수 없습니다"); + } + + @Test + @DisplayName("이미 존재하는 이메일이면 예외가 발생한다") + void fail_when_email_already_exists() { + String loginId = "user123"; + String rawPassword = "Password1!"; + LocalDate birthDate = LocalDate.of(1991, 12, 3); + String name = "김용권"; + String email = "yk@google.com"; + + given(userRepository.existsByEmail(email)).willReturn(true); + + assertThatThrownBy(() -> userService.createUser(loginId, rawPassword, birthDate, name, email)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미 가입된 이메일입니다"); + } + + @Test + @DisplayName("이미 존재하는 로그인 아이디면 예외가 발생한다") + void fail_when_loginId_already_exists() { + String loginId = "user123"; + String rawPassword = "Password1!"; + LocalDate birthDate = LocalDate.of(1991, 12, 3); + String name = "김용권"; + String email = "yk@google.com"; + + given(userRepository.existsByLoginId(loginId)).willReturn(true); + + assertThatThrownBy(() -> userService.createUser(loginId, rawPassword, birthDate, name, email)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미 사용 중인 아이디입니다"); + } + } + + @DisplayName("비밀번호 변경") + @Nested + class ChangePassword { + + @Test + @DisplayName("비밀번호 변경에 성공한다") + void success() { + Long userId = 1L; + String currentPassword = "OldPass1!"; + String newPassword = "NewPass1!"; + String encodedCurrentPassword = "encoded_old"; + String encodedNewPassword = "encoded_new"; + + UserModel user = UserModel.create("user123", encodedCurrentPassword, LocalDate.of(1991, 12, 3), "김용권", "yk@google.com"); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(passwordEncoder.matches(currentPassword, encodedCurrentPassword)).willReturn(true); + given(passwordEncoder.matches(newPassword, encodedCurrentPassword)).willReturn(false); + given(passwordEncoder.encode(newPassword)).willReturn(encodedNewPassword); + + userService.changePassword(userId, currentPassword, newPassword); + + assertThat(user.getPassword()).isEqualTo(encodedNewPassword); + } + + @Test + @DisplayName("기존 비밀번호가 일치하지 않으면 예외가 발생한다") + void fail_when_currentPassword_not_match() { + Long userId = 1L; + String currentPassword = "WrongPass!"; + String newPassword = "NewPass1!"; + String encodedCurrentPassword = "encoded_old"; + + UserModel user = UserModel.create("user123", encodedCurrentPassword, LocalDate.of(1991, 12, 3), "김용권", "yk@google.com"); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(passwordEncoder.matches(currentPassword, encodedCurrentPassword)).willReturn(false); + + assertThatThrownBy(() -> userService.changePassword(userId, currentPassword, newPassword)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("기존 비밀번호가 일치하지 않습니다"); + } + + @Test + @DisplayName("새 비밀번호가 기존 비밀번호와 같으면 예외가 발생한다") + void fail_when_newPassword_same_as_current() { + Long userId = 1L; + String currentPassword = "SamePass1!"; + String newPassword = "SamePass1!"; + String encodedCurrentPassword = "encoded_same"; + + UserModel user = UserModel.create("user123", encodedCurrentPassword, LocalDate.of(1991, 12, 3), "김용권", "yk@google.com"); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(passwordEncoder.matches(currentPassword, encodedCurrentPassword)).willReturn(true); + given(passwordEncoder.matches(newPassword, encodedCurrentPassword)).willReturn(true); + + assertThatThrownBy(() -> userService.changePassword(userId, currentPassword, newPassword)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("새 비밀번호는 기존 비밀번호와 달라야 합니다"); + } + + @Test + @DisplayName("새 비밀번호에 생년월일이 포함되면 예외가 발생한다") + void fail_when_newPassword_contains_birthDate() { + // arrange + Long userId = 1L; + String currentPassword = "OldPass1!"; + String newPassword = "Pass19911203!"; + String encodedCurrentPassword = "encoded_old"; + + UserModel user = UserModel.create("user123", encodedCurrentPassword, LocalDate.of(1991, 12, 3), "김용권", "yk@google.com"); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(passwordEncoder.matches(currentPassword, encodedCurrentPassword)).willReturn(true); + given(passwordEncoder.matches(newPassword, encodedCurrentPassword)).willReturn(false); + + // act & assert + assertThatThrownBy(() -> userService.changePassword(userId, currentPassword, newPassword)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("비밀번호에 생년월일을 포함할 수 없습니다"); + } + + @Test + @DisplayName("사용자를 찾을 수 없으면 예외가 발생한다") + void fail_when_user_not_found() { + // arrange + Long userId = 999L; + String currentPassword = "OldPass1!"; + String newPassword = "NewPass1!"; + + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + // act & assert + assertThatThrownBy(() -> userService.changePassword(userId, currentPassword, newPassword)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserControllerTest.java new file mode 100644 index 00000000..819ce7d6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserControllerTest.java @@ -0,0 +1,530 @@ +package com.loopers.interfaces.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.user.SignUpCommand; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.config.WebMvcConfig; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import com.loopers.interfaces.user.ChangePasswordRequest; +import com.loopers.interfaces.user.UsersController; +import com.loopers.interfaces.user.UsersSignUpRequestDto; +import java.time.LocalDate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(UsersController.class) +@Import({WebMvcConfig.class, CredentialsHeadersArgumentResolver.class, AuthUserArgumentResolver.class}) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UserFacade userFacade; + + @MockitoBean + private UserService userService; + + @Test + @DisplayName("회원가입 API 호출 테스트") + void success_signup() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + "yk@google.com" + ); + + UserInfo userInfo = new UserInfo(1L, "kim", "김용권", "yk@google.com", LocalDate.of(1991, 12, 3)); + given(userFacade.signUp(any(SignUpCommand.class))).willReturn(userInfo); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1!") + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(1)); + } + + @DisplayName("회원가입 API 실패 테스트") + @Nested + class UserSignupFailureTest { + + @Test + @DisplayName("이메일 형식이 잘못되면 400 Bad Request를 반환한다") + void fail_when_email_format_invalid() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + "invalid-email" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("이메일이 null이면 400 Bad Request를 반환한다") + void fail_when_email_null() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + null + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("이름이 null이면 400 Bad Request를 반환한다") + void fail_when_name_null() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + null, + "yk@google.com" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("이름이 빈 문자열이면 400 Bad Request를 반환한다") + void fail_when_name_empty() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "", + "yk@google.com" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("이름이 1자이면 400 Bad Request를 반환한다") + void fail_when_name_too_short() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김", + "yk@google.com" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("이름이 11자 이상이면 400 Bad Request를 반환한다") + void fail_when_name_too_long() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "가나다라마바사아자차카", + "yk@google.com" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("이름에 숫자가 포함되면 400 Bad Request를 반환한다") + void fail_when_name_contains_number() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권1", + "yk@google.com" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("이름에 특수문자가 포함되면 400 Bad Request를 반환한다") + void fail_when_name_contains_special_character() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권!", + "yk@google.com" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("생년월일이 null이면 400 Bad Request를 반환한다") + void fail_when_birthDate_null() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + null, + "김용권", + "yk@google.com" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("생년월일이 미래 날짜이면 400 Bad Request를 반환한다") + void fail_when_birthDate_future() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.now().plusDays(1), + "김용권", + "yk@google.com" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("비밀번호가 7자 이하면 400 Bad Request를 반환한다") + void fail_when_password_too_short() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + "yk@google.com" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Abc12!") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("비밀번호가 17자 이상이면 400 Bad Request를 반환한다") + void fail_when_password_too_long() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + "yk@google.com" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Abcd123!@#efgh456") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("비밀번호에 한글이 포함되면 400 Bad Request를 반환한다") + void fail_when_password_contains_korean() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + "yk@google.com" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1가") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("비밀번호에 공백이 포함되면 400 Bad Request를 반환한다") + void fail_when_password_contains_space() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + "yk@google.com" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password 1!") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("X-Loopers-LoginId 헤더가 없으면 400 Bad Request를 반환한다") + void fail_when_loginId_header_missing() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + "yk@google.com" + ); + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("로그인 ID에 특수문자가 포함되면 400 Bad Request를 반환한다") + void fail_when_loginId_contains_special_character() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + "yk@google.com" + ); + + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim!") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1!") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("X-Loopers-LoginPw 헤더가 없으면 400 Bad Request를 반환한다") + void fail_when_loginPw_header_missing() throws Exception { + UsersSignUpRequestDto requestBody = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + "yk@google.com" + ); + String json = objectMapper.writeValueAsString(requestBody); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("잘못된 JSON 형식이면 400 Bad Request를 반환한다") + void userSignupApiJsonInvalidTest() throws Exception { + String invalidJson = "{ invalid json }"; + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "Password1") + .content(invalidJson)) + .andExpect(status().isBadRequest()); + } + } + + @DisplayName("비밀번호 변경 API") + @Nested + class ChangePasswordTest { + + private UserModel mockUser() { + return UserModel.create("kim", "encodedOldPass", LocalDate.of(1991, 12, 3), "김용권", "yk@google.com"); + } + + @Test + @DisplayName("비밀번호 변경에 성공한다") + void success() throws Exception { + ChangePasswordRequest request = new ChangePasswordRequest("OldPass1!", "NewPass1!"); + String json = objectMapper.writeValueAsString(request); + + given(userService.authenticate("kim", "OldPass1!")).willReturn(mockUser()); + doNothing().when(userFacade).changePassword(any(), any(ChangePasswordRequest.class)); + + mockMvc.perform(patch("/users/me/password") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "OldPass1!") + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")); + } + + @Test + @DisplayName("기존 비밀번호가 null이면 400 Bad Request를 반환한다") + void fail_when_currentPassword_is_null() throws Exception { + ChangePasswordRequest request = new ChangePasswordRequest(null, "NewPass1!"); + String json = objectMapper.writeValueAsString(request); + + given(userService.authenticate("kim", "OldPass1!")).willReturn(mockUser()); + + mockMvc.perform(patch("/users/me/password") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "OldPass1!") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("새 비밀번호가 null이면 400 Bad Request를 반환한다") + void fail_when_newPassword_is_null() throws Exception { + ChangePasswordRequest request = new ChangePasswordRequest("OldPass1!", null); + String json = objectMapper.writeValueAsString(request); + + given(userService.authenticate("kim", "OldPass1!")).willReturn(mockUser()); + + mockMvc.perform(patch("/users/me/password") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "OldPass1!") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("새 비밀번호가 7자 이하면 400 Bad Request를 반환한다") + void fail_when_newPassword_too_short() throws Exception { + ChangePasswordRequest request = new ChangePasswordRequest("OldPass1!", "Pass1!"); + String json = objectMapper.writeValueAsString(request); + + given(userService.authenticate("kim", "OldPass1!")).willReturn(mockUser()); + + mockMvc.perform(patch("/users/me/password") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "OldPass1!") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("새 비밀번호가 17자 이상이면 400 Bad Request를 반환한다") + void fail_when_newPassword_too_long() throws Exception { + ChangePasswordRequest request = new ChangePasswordRequest("OldPass1!", "Password123456789!"); + String json = objectMapper.writeValueAsString(request); + + given(userService.authenticate("kim", "OldPass1!")).willReturn(mockUser()); + + mockMvc.perform(patch("/users/me/password") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "OldPass1!") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("새 비밀번호에 한글이 포함되면 400 Bad Request를 반환한다") + void fail_when_newPassword_contains_korean() throws Exception { + ChangePasswordRequest request = new ChangePasswordRequest("OldPass1!", "NewPass1가"); + String json = objectMapper.writeValueAsString(request); + + given(userService.authenticate("kim", "OldPass1!")).willReturn(mockUser()); + + mockMvc.perform(patch("/users/me/password") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "OldPass1!") + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("새 비밀번호에 공백이 포함되면 400 Bad Request를 반환한다") + void fail_when_newPassword_contains_space() throws Exception { + ChangePasswordRequest request = new ChangePasswordRequest("OldPass1!", "New Pass1!"); + String json = objectMapper.writeValueAsString(request); + + given(userService.authenticate("kim", "OldPass1!")).willReturn(mockUser()); + + mockMvc.perform(patch("/users/me/password") + .contentType(APPLICATION_JSON) + .header(LoopersHeaders.X_LOOPERS_LOGIN_ID, "kim") + .header(LoopersHeaders.X_LOOPERS_LOGIN_PW, "OldPass1!") + .content(json)) + .andExpect(status().isBadRequest()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersApiE2ETest.java new file mode 100644 index 00000000..1cfd7ef6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersApiE2ETest.java @@ -0,0 +1,294 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.user.UserModel; +import com.loopers.infrastructure.UserJpaRepository; +import com.loopers.interfaces.user.ChangePasswordRequest; +import com.loopers.interfaces.user.UserDto; +import com.loopers.interfaces.user.UsersSignUpRequestDto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UsersApiE2ETest { + + private static final String ENDPOINT_USERS = "/users"; + private static final String ENDPOINT_USERS_ME = "/users/me"; + private static final String ENDPOINT_USERS_ME_PASSWORD = "/users/me/password"; + + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UsersApiE2ETest( + TestRestTemplate testRestTemplate, + UserJpaRepository userJpaRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders createHeaders(String loginId, String loginPw) { + HttpHeaders headers = new HttpHeaders(); + headers.set(LoopersHeaders.X_LOOPERS_LOGIN_ID, loginId); + headers.set(LoopersHeaders.X_LOOPERS_LOGIN_PW, loginPw); + headers.set("Content-Type", "application/json"); + return headers; + } + + private UserModel createUser(String loginId, String rawPassword, LocalDate birthDate, String name, String email) { + String encodedPassword = passwordEncoder.encode(rawPassword); + UserModel user = UserModel.create(loginId, encodedPassword, birthDate, name, email); + return userJpaRepository.save(user); + } + + @DisplayName("회원 가입 테스트 (POST /users)") + @Nested + class SignUp { + + @Test + @DisplayName("회원가입에 성공 테스트") + void success() { + // arrange + String loginId = "yktest"; + String loginPw = "Password1!"; + UsersSignUpRequestDto requestDto = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + "test@google.com" + ); + + HttpHeaders headers = createHeaders(loginId, loginPw); + HttpEntity request = new HttpEntity<>(requestDto, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_USERS, HttpMethod.POST, request, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isNotNull() + ); + } + + @Test + @DisplayName("이미 존재하는 로그인 아이디면 400 응답 받는 실패 테스트") + void fail_when_loginId_already_exists() { + // arrange + createUser("existinguser", "Password1!", LocalDate.of(1990, 1, 1), "기존유저", "existing@google.com"); + + UsersSignUpRequestDto requestDto = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + "new@google.com" + ); + + HttpHeaders headers = createHeaders("existinguser", "Password1!"); + HttpEntity request = new HttpEntity<>(requestDto, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_USERS, HttpMethod.POST, request, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("이미 존재하는 이메일이면 400 응답을 받는다") + void fail_when_email_already_exists() { + // arrange + createUser("existinguser", "Password1!", LocalDate.of(1990, 1, 1), "기존유저", "existing@google.com"); + + UsersSignUpRequestDto requestDto = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + "existing@google.com" // 같은 이메일 (중복!) + ); + + HttpHeaders headers = createHeaders("newuser", "Password1!"); + HttpEntity request = new HttpEntity<>(requestDto, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_USERS, HttpMethod.POST, request, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("비밀번호에 생년월일이 포함되면 400 응답을 받는다") + void fail_when_password_contains_birthDate() { + // arrange + UsersSignUpRequestDto requestDto = new UsersSignUpRequestDto( + LocalDate.of(1991, 12, 3), + "김용권", + "test@google.com" + ); + + HttpHeaders headers = createHeaders("testuser", "Pass19911203!"); + HttpEntity request = new HttpEntity<>(requestDto, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_USERS, HttpMethod.POST, request, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("내 정보 조회 테스트 (GET /users/me)") + @Nested + class GetMe { + + @Test + @DisplayName("내 정보 조회 성공 테스트") + void success() { + // arrange + String loginId = "testuser"; + String rawPassword = "Password1!"; + createUser(loginId, rawPassword, LocalDate.of(1991, 12, 3), "김용권", "test@google.com"); + + HttpHeaders headers = createHeaders(loginId, rawPassword); + HttpEntity request = new HttpEntity<>(null, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_USERS_ME, HttpMethod.GET, request, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo(loginId), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@google.com") + ); + } + } + + @DisplayName("비밀번호 변경 테스트 (PATCH /users/me/password)") + @Nested + class ChangePassword { + + @Test + @DisplayName("비밀번호 변경 성공 테스트") + void success() { + // arrange + String loginId = "testuser"; + String currentPassword = "Password1!"; + String newPassword = "NewPassword1!"; + createUser(loginId, currentPassword, LocalDate.of(1991, 12, 3), "김용권", "test@google.com"); + + ChangePasswordRequest requestDto = new ChangePasswordRequest(currentPassword, newPassword); + HttpHeaders headers = createHeaders(loginId, currentPassword); + HttpEntity request = new HttpEntity<>(requestDto, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_USERS_ME_PASSWORD, HttpMethod.PATCH, request, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("현재 비밀번호가 틀리면 400 응답을 받는다") + void fail_when_current_password_not_match() { + // arrange + String loginId = "testuser"; + String currentPassword = "Password1!"; + createUser(loginId, currentPassword, LocalDate.of(1991, 12, 3), "김용권", "test@google.com"); + + ChangePasswordRequest requestDto = new ChangePasswordRequest("WrongPassword1!", "NewPassword1!"); + HttpHeaders headers = createHeaders(loginId, currentPassword); + HttpEntity request = new HttpEntity<>(requestDto, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_USERS_ME_PASSWORD, HttpMethod.PATCH, request, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("새 비밀번호가 기존 비밀번호와 같으면 400 응답을 받는다") + void fail_when_new_password_same_as_current() { + // arrange + String loginId = "testuser"; + String currentPassword = "Password1!"; + createUser(loginId, currentPassword, LocalDate.of(1991, 12, 3), "김용권", "test@google.com"); + + ChangePasswordRequest requestDto = new ChangePasswordRequest(currentPassword, currentPassword); + HttpHeaders headers = createHeaders(loginId, currentPassword); + HttpEntity request = new HttpEntity<>(requestDto, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_USERS_ME_PASSWORD, HttpMethod.PATCH, request, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("새 비밀번호에 생년월일이 포함되면 400 응답을 받는다") + void fail_when_new_password_contains_birthDate() { + // arrange + String loginId = "testuser"; + String currentPassword = "Password1!"; + createUser(loginId, currentPassword, LocalDate.of(1991, 12, 3), "김용권", "test@google.com"); + + ChangePasswordRequest requestDto = new ChangePasswordRequest(currentPassword, "Pass19911203!"); + HttpHeaders headers = createHeaders(loginId, currentPassword); + HttpEntity request = new HttpEntity<>(requestDto, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_USERS_ME_PASSWORD, HttpMethod.PATCH, request, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/dto/UserDtoValidationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/dto/UserDtoValidationTest.java new file mode 100644 index 00000000..084f7575 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/dto/UserDtoValidationTest.java @@ -0,0 +1,383 @@ +package com.loopers.interfaces.dto; + + +import static org.assertj.core.api.Assertions.assertThat; + +import com.loopers.interfaces.user.ChangePasswordRequest; +import com.loopers.interfaces.user.UsersSignUpRequestDto; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.time.LocalDate; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class UserDtoValidationTest { + + private static final LocalDate DEFAULT_BIRTH_DATE = LocalDate.of(1991, 12, 3); + private static final String DEFAULT_NAME = "김용권"; + private static final String DEFAULT_EMAIL = "yk@google.com"; + + private Validator validator; + + @BeforeEach + void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + private UsersSignUpRequestDto defaultDto() { + return new UsersSignUpRequestDto(DEFAULT_BIRTH_DATE, DEFAULT_NAME, DEFAULT_EMAIL); + } + + private UsersSignUpRequestDto dtoWithEmail(String email) { + return new UsersSignUpRequestDto(DEFAULT_BIRTH_DATE, DEFAULT_NAME, email); + } + + private UsersSignUpRequestDto dtoWithBirthDate(LocalDate birthDate) { + return new UsersSignUpRequestDto(birthDate, DEFAULT_NAME, DEFAULT_EMAIL); + } + + private UsersSignUpRequestDto dtoWithName(String name) { + return new UsersSignUpRequestDto(DEFAULT_BIRTH_DATE, name, DEFAULT_EMAIL); + } + + private Set> validate(UsersSignUpRequestDto dto) { + return validator.validate(dto); + } + + @DisplayName("이메일 검증") + @Nested + class EmailValidation { + + @Test + @DisplayName("이메일 포맷이 맞으면 성공하는 테스트") + void emailFormatSuccessTest() { + UsersSignUpRequestDto dto = defaultDto(); + + Set> violations = validate(dto); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("이메일 포맷이 안맞으면 실패하는 테스트") + void emailFormatFailTest() { + UsersSignUpRequestDto dto = dtoWithEmail("ykadasdad"); + + Set> violations = validate(dto); + + assertThat(violations).hasSize(1); + } + + @Test + @DisplayName("이메일에 null이 들어오면 실패하는 테스트") + void emailFormatNullTest() { + UsersSignUpRequestDto dto = dtoWithEmail(null); + + Set> violations = validate(dto); + + assertThat(violations).hasSize(1); + } + } + + @DisplayName("생년월일 검증") + @Nested + class BirthdayValidation { + + @Test + @DisplayName("포맷이 맞으면 성공하는 테스트") + void birthFormatSuccessTest() { + UsersSignUpRequestDto dto = defaultDto(); + + Set> violations = validate(dto); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("미래 날짜면 실패하는 테스트") + void birthFormatDateIsFutureFailTest() { + UsersSignUpRequestDto dto = dtoWithBirthDate(LocalDate.now().plusDays(1)); + + Set> violations = validate(dto); + + assertThat(violations).isNotEmpty(); + } + + @Test + @DisplayName("null이면 실패하는 테스트") + void birthFormatDateIsNullFailTest() { + UsersSignUpRequestDto dto = dtoWithBirthDate(null); + + Set> violations = validate(dto); + + assertThat(violations).isNotEmpty(); + } + } + + @DisplayName("이름 검증") + @Nested + class NameValidation { + + @Test + @DisplayName("올바른 한글 이름이면 검증에 통과한다") + void validKoreanSuccessTest() { + UsersSignUpRequestDto dto = defaultDto(); + + Set> violations = validate(dto); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("올바른 영문 이름이면 검증에 통과한다") + void validEnglishSuccessTest() { + UsersSignUpRequestDto dto = dtoWithName("John"); + + Set> violations = validate(dto); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("한글과 영문이 섞인 이름이면 검증에 통과한다") + void mixedKoreanAndEnglishSuccessTest() { + UsersSignUpRequestDto dto = dtoWithName("김John"); + + Set> violations = validate(dto); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("공백이 포함된 이름이면 검증에 통과한다") + void nameContainsSpaceSuccessTest() { + UsersSignUpRequestDto dto = dtoWithName("홍 길동"); + + Set> violations = validate(dto); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("이름이 2자이면 검증에 통과한다") + void nameIsMinLengthSuccessTest() { + UsersSignUpRequestDto dto = dtoWithName("김용"); + + Set> violations = validate(dto); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("이름이 null이면 검증에 실패한다") + void nameIsNullFailTest() { + UsersSignUpRequestDto dto = dtoWithName(null); + + Set> violations = validate(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("이름은 필수입니다."); + } + + @Test + @DisplayName("이름이 빈 문자열이면 검증에 실패한다") + void nameIsEmptyFailTest() { + UsersSignUpRequestDto dto = dtoWithName(""); + + Set> violations = validate(dto); + + assertThat(violations).isNotEmpty(); + } + + @Test + @DisplayName("이름이 공백만 있으면 검증에 실패한다") + void nameFormatBlankFailTest() { + UsersSignUpRequestDto dto = dtoWithName(" "); + + Set> violations = validate(dto); + + assertThat(violations).isNotEmpty(); + // NotBlank 또는 Pattern 위반 가능 (구현/순서에 따라 메시지 상이) + assertThat(violations.iterator().next().getMessage()) + .isIn("이름은 필수입니다.", "이름은 한글, 영문, 공백만 입력 가능합니다."); + } + + @Test + @DisplayName("이름이 1자이면 검증에 실패한다") + void nameFormatTooShortFailTest() { + UsersSignUpRequestDto dto = dtoWithName("김"); + + Set> violations = validate(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("이름은 2자 이상 30자 이하여야 합니다."); + } + + @Test + @DisplayName("이름이 11자 이상이면 검증에 실패한다") + void nameFormatTooLongFailTest() { + UsersSignUpRequestDto dto = dtoWithName("가나다라마바사아자차카"); + + Set> violations = validate(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("이름은 2자 이상 30자 이하여야 합니다."); + } + + @Test + @DisplayName("이름에 숫자가 포함되면 검증에 실패한다") + void nameFormatContainsNumberFailTest() { + UsersSignUpRequestDto dto = dtoWithName("김용권1"); + + Set> violations = validate(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("이름은 한글, 영문, 공백만 입력 가능합니다."); + } + + @Test + @DisplayName("이름에 특수문자가 포함되면 검증에 실패한다") + void nameFormatContainsSpecialCharacterFailTest() { + UsersSignUpRequestDto dto = dtoWithName("김용권!"); + + Set> violations = validate(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("이름은 한글, 영문, 공백만 입력 가능합니다."); + } + + @Test + @DisplayName("이름에 하이픈이 포함되면 검증에 실패한다") + void nameFormatContainsHyphenFailTest() { + UsersSignUpRequestDto dto = dtoWithName("김-용권"); + + Set> violations = validate(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("이름은 한글, 영문, 공백만 입력 가능합니다."); + } + + @Test + @DisplayName("이름에 점이 포함되면 검증에 실패한다") + void nameFormatContainsDotFailTest() { + UsersSignUpRequestDto dto = dtoWithName("김.용권"); + + Set> violations = validate(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("이름은 한글, 영문, 공백만 입력 가능합니다."); + } + } + + @DisplayName("비밀번호 변경 요청 검증") + @Nested + class ChangePasswordRequestValidation { + + private Set> validateChangePassword(ChangePasswordRequest dto) { + return validator.validate(dto); + } + + @Test + @DisplayName("올바른 요청이면 검증에 통과한다") + void validRequest() { + ChangePasswordRequest dto = new ChangePasswordRequest("OldPass1!", "NewPass1!"); + + Set> violations = validateChangePassword(dto); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("기존 비밀번호가 null이면 검증에 실패한다") + void fail_when_currentPassword_is_null() { + ChangePasswordRequest dto = new ChangePasswordRequest(null, "NewPass1!"); + + Set> violations = validateChangePassword(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("기존 비밀번호는 필수입니다."); + } + + @Test + @DisplayName("기존 비밀번호가 빈 문자열이면 검증에 실패한다") + void fail_when_currentPassword_is_blank() { + ChangePasswordRequest dto = new ChangePasswordRequest("", "NewPass1!"); + + Set> violations = validateChangePassword(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("기존 비밀번호는 필수입니다."); + } + + @Test + @DisplayName("새 비밀번호가 null이면 검증에 실패한다") + void fail_when_newPassword_is_null() { + ChangePasswordRequest dto = new ChangePasswordRequest("OldPass1!", null); + + Set> violations = validateChangePassword(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("새 비밀번호는 필수입니다."); + } + + @Test + @DisplayName("새 비밀번호가 빈 문자열이면 검증에 실패한다") + void fail_when_newPassword_is_blank() { + ChangePasswordRequest dto = new ChangePasswordRequest("OldPass1!", ""); + + Set> violations = validateChangePassword(dto); + + assertThat(violations).isNotEmpty(); + } + + @Test + @DisplayName("새 비밀번호가 7자 이하면 검증에 실패한다") + void fail_when_newPassword_too_short() { + ChangePasswordRequest dto = new ChangePasswordRequest("OldPass1!", "Pass1!"); + + Set> violations = validateChangePassword(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("비밀번호는 8~16자로 입력해주세요."); + } + + @Test + @DisplayName("새 비밀번호가 17자 이상이면 검증에 실패한다") + void fail_when_newPassword_too_long() { + ChangePasswordRequest dto = new ChangePasswordRequest("OldPass1!", "Password123456789!"); + + Set> violations = validateChangePassword(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("비밀번호는 8~16자로 입력해주세요."); + } + + @Test + @DisplayName("새 비밀번호에 한글이 포함되면 검증에 실패한다") + void fail_when_newPassword_contains_korean() { + ChangePasswordRequest dto = new ChangePasswordRequest("OldPass1!", "Password1가"); + + Set> violations = validateChangePassword(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("영문 대소문자, 숫자, 특수문자만 사용 가능합니다."); + } + + @Test + @DisplayName("새 비밀번호에 공백이 포함되면 검증에 실패한다") + void fail_when_newPassword_contains_space() { + ChangePasswordRequest dto = new ChangePasswordRequest("OldPass1!", "Pass word1!"); + + Set> violations = validateChangePassword(dto); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()).isEqualTo("영문 대소문자, 숫자, 특수문자만 사용 가능합니다."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/support/MaskingUtilsTest.java b/apps/commerce-api/src/test/java/com/loopers/support/MaskingUtilsTest.java new file mode 100644 index 00000000..0d1bbb9d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/support/MaskingUtilsTest.java @@ -0,0 +1,41 @@ +package com.loopers.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MaskingUtilsTest { + + @DisplayName("마지막 글자를 *로 마스킹한다.") + @Test + void maskLastCharacter_success() { + String result = MaskingUtils.maskLastCharacter("김용권"); + + assertThat(result).isEqualTo("김용*"); + } + + @DisplayName("1글자인 경우 *만 반환한다.") + @Test + void maskLastCharacter_singleCharacter() { + String result = MaskingUtils.maskLastCharacter("김"); + + assertThat(result).isEqualTo("*"); + } + + @DisplayName("빈 문자열이면 빈 문자열을 반환한다.") + @Test + void maskLastCharacter_emptyString() { + String result = MaskingUtils.maskLastCharacter(""); + + assertThat(result).isEqualTo(""); + } + + @DisplayName("null이면 null을 반환한다.") + @Test + void maskLastCharacter_null() { + String result = MaskingUtils.maskLastCharacter(null); + + assertThat(result).isNull(); + } +} diff --git a/http/commerce-api/users.http b/http/commerce-api/users.http new file mode 100644 index 00000000..5e758103 --- /dev/null +++ b/http/commerce-api/users.http @@ -0,0 +1,17 @@ +### 회원가입 +POST {{commerce-api}}/users +Content-Type: application/json +X-Loopers-LoginId: user123 +X-Loopers-LoginPw: Password1! + +{ + "birthDate": "1991-12-03", + "name": "김용권", + "email": "yk@google.com" +} + +### 내 정보 조회 +GET {{commerce-api}}/users/me +Content-Type: application/json +X-Loopers-LoginId: user123 +X-Loopers-LoginPw: Password1!