diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e..00000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## πŸ§ͺ Implementation Quest - -> μ§€μ •λœ **λ‹¨μœ„ ν…ŒμŠ€νŠΈ / 톡합 ν…ŒμŠ€νŠΈ / E2E ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€**λ₯Ό ν•„μˆ˜λ‘œ κ΅¬ν˜„ν•˜κ³ , λͺ¨λ“  ν…ŒμŠ€νŠΈλ₯Ό ν†΅κ³Όμ‹œν‚€λŠ” 것을 λͺ©ν‘œλ‘œ ν•©λ‹ˆλ‹€. - -### νšŒμ› κ°€μž… - -**🧱 λ‹¨μœ„ ν…ŒμŠ€νŠΈ** - -- [ ] ID κ°€ `영문 및 숫자 10자 이내` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. -- [ ] 이메일이 `xx@yy.zz` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. -- [ ] 생년월일이 `yyyy-MM-dd` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] νšŒμ› κ°€μž…μ‹œ User μ €μž₯이 μˆ˜ν–‰λœλ‹€. ( spy 검증 ) -- [ ] 이미 κ°€μž…λœ ID 둜 νšŒμ›κ°€μž… μ‹œλ„ μ‹œ, μ‹€νŒ¨ν•œλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] νšŒμ› κ°€μž…μ΄ 성곡할 경우, μƒμ„±λœ μœ μ € 정보λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] νšŒμ› κ°€μž… μ‹œμ— 성별이 없을 경우, `400 Bad Request` 응닡을 λ°˜ν™˜ν•œλ‹€. - -### λ‚΄ 정보 쑰회 - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•  경우, νšŒμ› 정보가 λ°˜ν™˜λœλ‹€. -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ 경우, null 이 λ°˜ν™˜λœλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] λ‚΄ 정보 μ‘°νšŒμ— 성곡할 경우, ν•΄λ‹Ήν•˜λŠ” μœ μ € 정보λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ID 둜 μ‘°νšŒν•  경우, `404 Not Found` 응닡을 λ°˜ν™˜ν•œλ‹€. - -### 포인트 쑰회 - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•  경우, 보유 ν¬μΈνŠΈκ°€ λ°˜ν™˜λœλ‹€. -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ 경우, null 이 λ°˜ν™˜λœλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] 포인트 μ‘°νšŒμ— 성곡할 경우, 보유 포인트λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] `X-USER-ID` 헀더가 없을 경우, `400 Bad Request` 응닡을 λ°˜ν™˜ν•œλ‹€. 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/SignUpCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/SignUpCommand.java new file mode 100644 index 00000000..12eef0a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/SignUpCommand.java @@ -0,0 +1,23 @@ +package com.loopers.application.user; + +import com.loopers.interfaces.api.user.dto.UserV1Dto; + +import java.time.LocalDate; + +public record SignUpCommand( + String loginId, + String password, + String name, + LocalDate birthDate, + String email +) { + public static SignUpCommand from(UserV1Dto.CreateRequest request) { + return new SignUpCommand( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UpdatePasswordCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UpdatePasswordCommand.java new file mode 100644 index 00000000..ebf9f0f5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UpdatePasswordCommand.java @@ -0,0 +1,13 @@ +package com.loopers.application.user; + +import com.loopers.interfaces.api.user.dto.UserV1Dto; + +public record UpdatePasswordCommand( + String loginId, + String currentPassword, + String newPassword +) { + public static UpdatePasswordCommand from(String loginId, String currentPassword, UserV1Dto.UpdatePasswordRequest request) { + return new UpdatePasswordCommand(loginId, 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..abc8599e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +import java.time.LocalDate; + +public record UserInfo(String loginId, String name, LocalDate birthDate, String email) { + public static UserInfo from(User user) { + return new UserInfo( + user.getLoginId(), + maskLastChar(user.getName()), + user.getBirthDate(), + user.getEmail() + ); + } + + private static String maskLastChar(String name) { + if (name == null || name.isBlank()) return name; + if (name.length() == 1) return "*"; + + return name.substring(0, name.length() - 1) + "*"; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java new file mode 100644 index 00000000..30ea71ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java @@ -0,0 +1,6 @@ +package com.loopers.domain.user; + +public interface PasswordEncoder { + String encode(String rawPassword); + boolean matches(String rawPassword, String encodedPassword); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicyValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicyValidator.java new file mode 100644 index 00000000..b1fa4455 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicyValidator.java @@ -0,0 +1,25 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +public class PasswordPolicyValidator { + + private static final DateTimeFormatter BIRTH_DATE_FORMAT_FOR_PASSWORD_CHECK = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final String PASSWORD_PATTERN = "^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?~`]{8,16}$"; + + private PasswordPolicyValidator() {} + + public static void validate(String password, LocalDate birthDate) { + if (!password.matches(PASSWORD_PATTERN)) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” 8~16자의 영문/숫자/특수문자만 κ°€λŠ₯ν•©λ‹ˆλ‹€."); + } + + String birthDateString = birthDate.format(BIRTH_DATE_FORMAT_FOR_PASSWORD_CHECK); + if (password.contains(birthDateString)) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜Έμ— 생년월일을 포함할 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpService.java new file mode 100644 index 00000000..a24218e1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpService.java @@ -0,0 +1,23 @@ +package com.loopers.domain.user; + +import com.loopers.application.user.SignUpCommand; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@Transactional +@RequiredArgsConstructor +public class SignUpService { + private final SignUpValidator signUpValidator; + private final PasswordEncoder passwordEncoder; + private final UserRepository userRepository; + + public void signUp(SignUpCommand command) { + signUpValidator.validate(command); + String encodedPassword = passwordEncoder.encode(command.password()); + User user = User.create(command, encodedPassword); + + userRepository.save(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpValidator.java new file mode 100644 index 00000000..38c4d2a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpValidator.java @@ -0,0 +1,27 @@ +package com.loopers.domain.user; + +import com.loopers.application.user.SignUpCommand; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +@RequiredArgsConstructor +public class SignUpValidator { + private final UserRepository userRepository; + + public void validate(SignUpCommand command) { + if (userRepository.findByLoginId(command.loginId()).isPresent()) { + throw new CoreException(ErrorType.CONFLICT, "이미 μ‘΄μž¬ν•˜λŠ” 둜그인 IDμž…λ‹ˆλ‹€."); + } + + if (command.birthDate().isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래일 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + PasswordPolicyValidator.validate(command.password(), command.birthDate()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 00000000..7e57a997 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,95 @@ +package com.loopers.domain.user; + +import com.loopers.application.user.SignUpCommand; +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.Table; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Entity +@Table(name = "users") +public class User extends BaseEntity { + @Column(unique = true) + private String loginId; + private String password; // encoded + private String name; + private LocalDate birthDate; + private String email; + + protected User() {} + + private User(String loginId, String encodedPassword, String name, LocalDate birthDate, String email) { + validateLoginId(loginId); + validateEncodedPassword(encodedPassword); + validateName(name); + validateBirthDate(birthDate); + validateEmail(email); + + this.loginId = loginId; + this.password = encodedPassword; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public static User create(String loginId, String encodedPassword, String name, LocalDate birthDate, String email) { + return new User(loginId, encodedPassword, name, birthDate, email); + } + + public static User create(SignUpCommand command, String encodedPassword) { + return new User(command.loginId(), encodedPassword, command.name(), command.birthDate(), command.email()); + + } + + private void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "둜그인 IDλŠ” ν•„μˆ˜κ°’μž…λ‹ˆλ‹€."); + } + + if (!loginId.matches("^[a-zA-Z0-9]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "둜그인 IDλŠ” 영문/숫자만 κ°€λŠ₯ν•©λ‹ˆλ‹€."); + } + } + + private void validateEncodedPassword(String encodedPassword) { + if (encodedPassword == null || encodedPassword.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜κ°’μž…λ‹ˆλ‹€."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 ν•„μˆ˜κ°’μž…λ‹ˆλ‹€."); + } + + if (name.length() < 2) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 μ΅œμ†Œ λ‘κΈ€μž 이상이어야 ν•©λ‹ˆλ‹€."); + } + } + + private void validateBirthDate(LocalDate birthDate) { + if (birthDate == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 ν•„μˆ˜κ°’μž…λ‹ˆλ‹€."); + } + } + + private void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 ν•„μˆ˜κ°’μž…λ‹ˆλ‹€."); + } + + if (!email.contains("@") || !email.matches("^[\\w\\.]+@[\\w\\.]+\\.[a-zA-Z]{2,}$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + } + + public void updatePassword(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..d33a021a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + void save(User user); + Optional findByLoginId(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..160d6a90 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,45 @@ +package com.loopers.domain.user; + +import com.loopers.application.user.UpdatePasswordCommand; +import com.loopers.application.user.UserInfo; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class UserService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public void updatePassword(UpdatePasswordCommand command) { + User user = getUser(command.loginId()); + + if (!passwordEncoder.matches(command.currentPassword(), user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + if (passwordEncoder.matches(command.newPassword(), user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•œ λΉ„λ°€λ²ˆν˜Έλ‘œ λ³€κ²½ν•  수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + PasswordPolicyValidator.validate(command.newPassword(), user.getBirthDate()); + String encoded = passwordEncoder.encode(command.newPassword()); + user.updatePassword(encoded); + } + + @Transactional(readOnly = true) + public UserInfo getMyInfo(String loginId) { + User user = getUser(loginId); + return UserInfo.from(user); + } + + private User getUser(String loginId) { + return userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + "[loginId = " + loginId + "] λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BcryptPasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BcryptPasswordEncoder.java new file mode 100644 index 00000000..dc75c1de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BcryptPasswordEncoder.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.PasswordEncoder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class BcryptPasswordEncoder implements PasswordEncoder { + private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + + @Override + public String encode(String rawPassword) { + return encoder.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encoder.matches(rawPassword, encodedPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 00000000..d89ee854 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 00000000..b0f42f5d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class UserRepositoryImpl implements UserRepository { + private final UserJpaRepository userJpaRepository; + + + @Override + public void save(User user) { + userJpaRepository.save(user); + } + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(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..8a438fb5 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 @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +47,14 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { + String errorMessage = e.getBindingResult().getFieldErrors().stream() + .map(error -> error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + return failureResponse(ErrorType.BAD_REQUEST, errorMessage); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/SignUpV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/SignUpV1Controller.java new file mode 100644 index 00000000..90ae6e0e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/SignUpV1Controller.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.SignUpCommand; +import com.loopers.domain.user.SignUpService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.user.dto.UserV1Dto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class SignUpV1Controller { + + private final SignUpService signUpService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse signUp(@Valid @RequestBody UserV1Dto.CreateRequest request) { + SignUpCommand command = SignUpCommand.from(request); + signUpService.signUp(command); + + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 00000000..d4d219da --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UpdatePasswordCommand; +import com.loopers.domain.user.UserService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.user.dto.UserV1Dto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller { + + private final UserService userService; + + @GetMapping("/me") + public ApiResponse getMyInfo(@RequestHeader("X-Loopers-LoginId") String loginId) { + UserInfo userInfo = userService.getMyInfo(loginId); + return ApiResponse.success(UserV1Dto.UserResponse.from(userInfo)); + } + + @PatchMapping("/me/password") + public ApiResponse updatePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String currentPassword, + @Valid @RequestBody UserV1Dto.UpdatePasswordRequest request + ) { + userService.updatePassword(UpdatePasswordCommand.from(loginId, currentPassword, request)); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserV1Dto.java new file mode 100644 index 00000000..6fa7ccb3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserV1Dto.java @@ -0,0 +1,41 @@ +package com.loopers.interfaces.api.user.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.loopers.application.user.UserInfo; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public class UserV1Dto { + + public record CreateRequest( + @NotBlank(message = "둜그인 IDλŠ” ν•„μˆ˜κ°’μž…λ‹ˆλ‹€.") + String loginId, + @NotBlank(message = "λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜κ°’μž…λ‹ˆλ‹€.") + String password, + @NotBlank(message = "이름은 ν•„μˆ˜κ°’μž…λ‹ˆλ‹€.") + String name, + @NotNull(message = "생년월일은 ν•„μˆ˜κ°’μž…λ‹ˆλ‹€.") + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate birthDate, + @NotBlank(message = "이메일은 ν•„μˆ˜κ°’μž…λ‹ˆλ‹€.") + String email + ) {} + + public record UpdatePasswordRequest( + @NotBlank(message = "μƒˆ λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜κ°’μž…λ‹ˆλ‹€.") + String newPassword + ) {} + + public record UserResponse(String loginId, String name, LocalDate birthDate, String email) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.loginId(), + info.name(), + info.birthDate(), + info.email() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/InMemoryUserRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/InMemoryUserRepository.java new file mode 100644 index 00000000..9c1535db --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/InMemoryUserRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.user; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class InMemoryUserRepository implements UserRepository { + private final Map storeByEmail = new HashMap<>(); + private final Map storeByLoginId = new HashMap<>(); + + @Override + public void save(User user) { + storeByEmail.put(user.getEmail(), user); + storeByLoginId.put(user.getLoginId(), user); + } + + @Override + public Optional findByLoginId(String loginId) { + return Optional.ofNullable(storeByLoginId.get(loginId)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpServiceIntegrationTest.java new file mode 100644 index 00000000..6766179a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpServiceIntegrationTest.java @@ -0,0 +1,55 @@ +package com.loopers.domain.user; + +import com.loopers.application.user.SignUpCommand; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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 com.loopers.infrastructure.user.UserJpaRepository; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +public class SignUpServiceIntegrationTest { + @Autowired + private SignUpService signUpService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("νšŒμ›κ°€μž…ν•˜λ©΄ DB에 μ €μž₯λœλ‹€.") + void signUp_savesUser() { + // arrange + SignUpCommand command = new SignUpCommand( + "testUser123", + "ValidPass1!", + "λ°•μžλ°”", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + signUpService.signUp(command); + + // assert + User savedUser = userJpaRepository.findByLoginId("testUser123") + .orElseThrow(() -> new AssertionError("User not saved")); + + assertThat(savedUser.getLoginId()).isEqualTo("testUser123"); + assertThat(savedUser.getId()).isGreaterThan(0L); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpServiceTest.java new file mode 100644 index 00000000..b1b09d19 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpServiceTest.java @@ -0,0 +1,47 @@ +package com.loopers.domain.user; + +import com.loopers.application.user.SignUpCommand; +import com.loopers.infrastructure.user.BcryptPasswordEncoder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +public class SignUpServiceTest { + private InMemoryUserRepository userRepository; + private PasswordEncoder passwordEncoder; + private SignUpValidator signUpValidator; + private SignUpService signUpService; + + @BeforeEach + void setUp() { + userRepository = new InMemoryUserRepository(); + passwordEncoder = new BcryptPasswordEncoder(); + signUpValidator = new SignUpValidator(userRepository); + signUpService = new SignUpService(signUpValidator, passwordEncoder, userRepository); + } + + @Test + @DisplayName("νšŒμ›κ°€μž…μ‹œ λΉ„λ°€λ²ˆν˜Έλ₯Ό μ•”ν˜Έν™”ν•΄μ„œ μ €μž₯ν•œλ‹€.") + void signUp_encryptsPassword() { + // arrange + SignUpCommand command = new SignUpCommand( + "testUser123", + "ValidPass1!", + "λ°•μžλ°”", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + signUpService.signUp(command); + + // assert + User savedUser = userRepository.findByLoginId("testUser123").orElse(null); + assertThat(savedUser).isNotNull(); + assertThat(savedUser.getPassword()).isNotEqualTo("ValidPass1!"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpValidatorTest.java new file mode 100644 index 00000000..4ac27b40 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpValidatorTest.java @@ -0,0 +1,143 @@ +package com.loopers.domain.user; + +import com.loopers.application.user.SignUpCommand; +import com.loopers.infrastructure.user.BcryptPasswordEncoder; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class SignUpValidatorTest { + private InMemoryUserRepository userRepository; + private PasswordEncoder passwordEncoder; + private SignUpValidator signUpValidator; + private SignUpService signUpService; + + @BeforeEach + void setUp() { + userRepository = new InMemoryUserRepository(); + passwordEncoder = new BcryptPasswordEncoder(); + signUpValidator = new SignUpValidator(userRepository); + signUpService = new SignUpService(signUpValidator, passwordEncoder, userRepository); + } + + @Test + @DisplayName("νšŒμ›κ°€μž…μ‹œ loginIdκ°€ μ€‘λ³΅λ˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + void validate_throwsException_whenLoginIdIsDuplicated() { + // arrange + SignUpCommand firstCommand = new SignUpCommand( + "duplicateId", + "ValidPass1!", + "λ°•μžλ°”", + LocalDate.of(1990, 1, 15), + "first@example.com" + ); + signUpService.signUp(firstCommand); + + SignUpCommand secondCommand = new SignUpCommand( + "duplicateId", + "ValidPass2!", + "κΉ€μžλ°”", + LocalDate.of(1995, 5, 20), + "second@example.com" + ); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + signUpValidator.validate(secondCommand); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @Test + @DisplayName("νšŒμ›κ°€μž…μ‹œ passwordκ°€ 8자 미만이면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + void validate_throwsException_whenPasswordIsTooShort() { + // arrange + SignUpCommand command = new SignUpCommand( + "testUser", + "Short1!", + "λ°•μžλ°”", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + signUpValidator.validate(command); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("νšŒμ›κ°€μž…μ‹œ passwordκ°€ 16자 초과면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + void validate_throwsException_whenPasswordIsTooLong() { + // arrange + SignUpCommand command = new SignUpCommand( + "testUser", + "VeryLongPass123!!", + "λ°•μžλ°”", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + signUpValidator.validate(command); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("νšŒμ›κ°€μž…μ‹œ password에 곡백이 ν¬ν•¨λ˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + void validate_throwsException_whenPasswordContainsWhitespace() { + // arrange + SignUpCommand command = new SignUpCommand( + "testUser", + "Pass 1234!", + "λ°•μžλ°”", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + signUpValidator.validate(command); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("νšŒμ›κ°€μž…μ‹œ password에 생년월일이 ν¬ν•¨λ˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + void validate_throwsException_whenPasswordContainsBirthDate() { + // arrange + SignUpCommand command = new SignUpCommand( + "testUser", + "Pass19900115!", + "λ°•μžλ°”", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + signUpValidator.validate(command); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserFixture.java new file mode 100644 index 00000000..b02fa210 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserFixture.java @@ -0,0 +1,50 @@ +package com.loopers.domain.user; + +import java.time.LocalDate; + +public class UserFixture { + public static final String VALID_LOGIN_ID = "testUser123"; + public static final String VALID_ENCODED_PASSWORD = "ValidEncodedPass1!"; + public static final String VALID_NAME = "λ°•μ½”ν‹€λ¦°"; + public static final LocalDate VALID_BIRTH_DATE = LocalDate.of(1990, 1, 15); + public static final String VALID_EMAIL = "test@example.com"; + + private String loginId = VALID_LOGIN_ID; + private String password = VALID_ENCODED_PASSWORD; + private String name = VALID_NAME; + private LocalDate birthDate = VALID_BIRTH_DATE; + private String email = VALID_EMAIL; + + public static UserFixture builder() { + return new UserFixture(); + } + + public UserFixture loginId(String loginId) { + this.loginId = loginId; + return this; + } + + public UserFixture password(String password) { + this.password = password; + return this; + } + + public UserFixture name(String name) { + this.name = name; + return this; + } + + public UserFixture birthDate(LocalDate birthDate) { + this.birthDate = birthDate; + return this; + } + + public UserFixture email(String email) { + this.email = email; + return this; + } + + public User build() { + return User.create(loginId, password, name, birthDate, email); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 00000000..292d9231 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,80 @@ +package com.loopers.domain.user; + +import com.loopers.application.user.UpdatePasswordCommand; +import com.loopers.application.user.UserInfo; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.bcrypt.BCryptPasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("λ‚΄ 정보 쑰회 μ‹œ 이름 λ§ˆμ§€λ§‰ κΈ€μžκ°€ λ§ˆμŠ€ν‚Ήλœλ‹€.") + void getMyInfo_returnsUserInfoWithMaskedName() { + // arrange + String loginId = "testUser123"; + User user = UserFixture.builder() + .loginId(loginId) + .name("λ°•μžλ°”") + .build(); + userJpaRepository.save(user); + + // act + UserInfo result = userService.getMyInfo(loginId); + + // assert + assertAll( + () -> assertThat(result.loginId()).isEqualTo(loginId), + () -> assertThat(result.name()).isEqualTo("λ°•μž*") + ); + } + + @Test + @DisplayName("λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ μ‹œ μƒˆ λΉ„λ°€λ²ˆν˜Έλ‘œ μ—…λ°μ΄νŠΈλœλ‹€.") + void updatePassword_updatesPasswordInDb() { + // arrange + String loginId = "testUser123"; + String currentPassword = "OldPass1!"; + String encode = bCryptPasswordEncoder.encode(currentPassword); + User user = UserFixture.builder() + .loginId(loginId) + .password(encode) + .build(); + userJpaRepository.save(user); + + String newPassword = "NewPass1!"; + UpdatePasswordCommand command = new UpdatePasswordCommand(loginId, currentPassword, newPassword); + + // act + userService.updatePassword(command); + + // assert + User updatedUser = userJpaRepository.findByLoginId(loginId).orElseThrow(); + assertThat(bCryptPasswordEncoder.matches(newPassword, updatedUser.getPassword())).isTrue(); + } +} 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..bf0516c9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -0,0 +1,90 @@ +package com.loopers.domain.user; + +import com.loopers.application.user.UpdatePasswordCommand; +import com.loopers.application.user.UserInfo; +import com.loopers.infrastructure.user.BcryptPasswordEncoder; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class UserServiceTest { + private InMemoryUserRepository userRepository; + private PasswordEncoder passwordEncoder; + private UserService userService; + + @BeforeEach + void setUp() { + userRepository = new InMemoryUserRepository(); + passwordEncoder = new BcryptPasswordEncoder(); + userService = new UserService(userRepository, passwordEncoder); + } + + @DisplayName("λ‚΄ 정보 쑰회 μ‹œ μ΄λ¦„μ˜ λ§ˆμ§€λ§‰ κΈ€μžλŠ” λ§ˆμŠ€ν‚Ή(*)λ˜μ–΄ λ°˜ν™˜λœλ‹€") + @Test + void getMyInfo_masks_last_character_of_name() { + // given + User user = UserFixture.builder() + .name("ν…ŒμŠ€ν„°") + .build(); + + userRepository.save(user); + + // when + UserInfo myInfo = userService.getMyInfo(user.getLoginId()); + + // then + assertThat(myInfo.name()).isEqualTo("ν…ŒμŠ€*"); + assertThat(myInfo.loginId()).isEqualTo(user.getLoginId()); + assertThat(myInfo.email()).isEqualTo(user.getEmail()); + assertThat(myInfo.birthDate()).isEqualTo(user.getBirthDate()); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμœΌλ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenCurrentPasswordNotMatches() { + // given + String currentPassword = "ValidPass1!"; + String encodedPassword = passwordEncoder.encode(currentPassword); + User user = UserFixture.builder() + .password(encodedPassword) + .build(); + userRepository.save(user); + + UpdatePasswordCommand command = new UpdatePasswordCommand(user.getLoginId(), "WrongPass1!", "NewPass1!"); + + // when + CoreException result = assertThrows(CoreException.class, () -> { + userService.updatePassword(command); + }); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ κ°™μœΌλ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenNewPasswordSameAsCurrent() { + // given + String rawPassword = "ValidPass1!"; + String encodedPassword = passwordEncoder.encode(rawPassword); + User user = UserFixture.builder() + .password(encodedPassword) + .build(); + userRepository.save(user); + + UpdatePasswordCommand command = new UpdatePasswordCommand(user.getLoginId(), rawPassword, rawPassword); + + // when + CoreException result = assertThrows(CoreException.class, () -> { + userService.updatePassword(command); + }); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java new file mode 100644 index 00000000..72af6858 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,148 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class UserTest { + @DisplayName("νšŒμ› κ°€μž…μ‹œ, ") + @Nested + class Create { + @DisplayName("loginIdκ°€ 영문/숫자만 ν¬ν•¨ν•˜λ©΄ μ •μƒμ μœΌλ‘œ μƒμ„±λœλ‹€.") + @Test + void createsUser_whenLoginIdIsAlphanumeric() { + // arrange + String loginId = "uniqueTester123"; + + // act + User user = UserFixture.builder() + .loginId(loginId) + .build(); + // assert + assertAll( + () -> assertThat(user.getLoginId()).isNotNull() + ); + } + + @DisplayName("loginId에 영문/숫자 μ™Έμ˜ λ¬Έμžκ°€ ν¬ν•¨λ˜λ©΄ BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenLoginIdIsNotAlphanumeric() { + // arrange + String loginId = "invalidId#?*"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + UserFixture.builder() + .loginId(loginId) + .build(); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 곡백 μ—†λŠ” λ¬Έμžμ—΄μ΄λ©΄ μ •μƒμ μœΌλ‘œ μƒμ„±λœλ‹€.") + @Test + void createsUser_whenNameIsValid() { + // arrange + String name = "λ°•μžλ°”"; + + // act + User user = UserFixture.builder() + .name(name) + .build(); + + // assert + assertThat(user.getName()).isEqualTo(name); + } + + @DisplayName("name이 빈 λ¬Έμžμ—΄μ΄λ©΄ BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenNameIsEmpty() { + // arrange + String name = ""; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + UserFixture.builder() + .name(name) + .build(); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("email이 @와 .을 ν¬ν•¨ν•˜λ©΄ μ •μƒμ μœΌλ‘œ μƒμ„±λœλ‹€.") + @Test + void createsUser_whenEmailIsValid() { + // arrange + String email = "test@example.com"; + + // act + User user = UserFixture.builder() + .email(email) + .build(); + + // assert + assertThat(user.getEmail()).isEqualTo(email); + } + + @DisplayName("email에 @κ°€ μ—†μœΌλ©΄ BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenEmailHasNoAtSign() { + // arrange + String email = "testexample.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + UserFixture.builder() + .email(email) + .build(); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("email에 .이 μ—†μœΌλ©΄ BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenEmailHasNoDot() { + // arrange + String email = "test@examplecom"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + UserFixture.builder() + .email(email) + .build(); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDateκ°€ μœ νš¨ν•œ LocalDate이면 μ •μƒμ μœΌλ‘œ μƒμ„±λœλ‹€.") + @Test + void createsUser_whenBirthDateIsValid() { + // arrange + LocalDate birthDate = LocalDate.of(1990, 1, 15); + + // act + User user = UserFixture.builder() + .birthDate(birthDate) + .build(); + + // assert + assertThat(user.getBirthDate()).isEqualTo(birthDate); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/SignUpV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/SignUpV1ApiE2ETest.java new file mode 100644 index 00000000..87a8508c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/SignUpV1ApiE2ETest.java @@ -0,0 +1,129 @@ +package com.loopers.interfaces.api; + +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.user.dto.UserV1Dto; +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.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +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 SignUpV1ApiE2ETest { + + private static final String ENDPOINT_SIGN_UP = "/api/v1/users"; + + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public SignUpV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserJpaRepository userJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/users (νšŒμ›κ°€μž…)") + @Nested + class SignUp { + @DisplayName("μœ νš¨ν•œ νšŒμ›κ°€μž… μš”μ²­μ΄λ©΄, 201 Created 응닡을 λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsCreated_whenValidRequest() { + // arrange + UserV1Dto.CreateRequest request = new UserV1Dto.CreateRequest( + "testUser123", + "ValidPass1!", + "λ°•μžλ°”", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(userJpaRepository.findByLoginId("testUser123")).isPresent() + ); + } + + @DisplayName("loginIdκ°€ λˆ„λ½λ˜λ©΄, 400 Bad Request 응닡을 λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsBadRequest_whenLoginIdIsMissing() { + // arrange + UserV1Dto.CreateRequest request = new UserV1Dto.CreateRequest( + null, + "ValidPass1!", + "λ°•μžλ°”", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("이미 μ‘΄μž¬ν•˜λŠ” loginId둜 μš”μ²­ν•˜λ©΄, 409 Conflict 응닡을 λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsConflict_whenLoginIdAlreadyExists() { + // arrange + UserV1Dto.CreateRequest firstRequest = new UserV1Dto.CreateRequest( + "duplicateId", + "ValidPass1!", + "λ°•μžλ°”", + LocalDate.of(1990, 1, 15), + "first@example.com" + ); + + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(firstRequest), + new ParameterizedTypeReference>() {}); + + UserV1Dto.CreateRequest secondRequest = new UserV1Dto.CreateRequest( + "duplicateId", + "ValidPass2!", + "κΉ€μžλ°”", + LocalDate.of(1995, 5, 20), + "second@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGN_UP, HttpMethod.POST, new HttpEntity<>(secondRequest), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java new file mode 100644 index 00000000..66db6ea6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,225 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserFixture; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.user.dto.UserV1Dto; +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.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + + private static final String ENDPOINT_GET_MY_INFO = "/api/v1/users/me"; + private static final String ENDPOINT_UPDATE_PASSWORD = "/api/v1/users/me/password"; + + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserJpaRepository userJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/users/me (λ‚΄ 정보 쑰회)") + @Nested + class GetMyInfo { + + @DisplayName("μ‘΄μž¬ν•˜λŠ” μ‚¬μš©μžλ₯Ό μ‘°νšŒν•˜λ©΄, 200 OK와 λ§ˆμŠ€ν‚Ήλœ 이름을 λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsOk_whenUserExists() { + // arrange + User savedUser = UserFixture.builder() + .loginId("testUser123") + .name("λ°•μžλ°”") + .build(); + userJpaRepository.save(savedUser); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", savedUser.getLoginId()); + HttpEntity requestEntity = new HttpEntity<>(headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_GET_MY_INFO, HttpMethod.GET, requestEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testUser123"), + () -> assertThat(response.getBody().data().name()).isEqualTo("λ°•μž*") + ); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ‚¬μš©μžλ₯Ό μ‘°νšŒν•˜λ©΄, 404 Not Foundλ₯Ό λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsNotFound_whenUserNotExists() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nonExistingId"); + HttpEntity requestEntity = new HttpEntity<>(headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_GET_MY_INFO, HttpMethod.GET, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PATCH /api/v1/users/me/password (λΉ„λ°€λ²ˆν˜Έ λ³€κ²½)") + @Nested + class UpdatePassword { + + private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); + + @DisplayName("μœ νš¨ν•œ λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ μš”μ²­μ΄λ©΄, 200 OKλ₯Ό λ°˜ν™˜ν•˜κ³  λΉ„λ°€λ²ˆν˜Έκ°€ λ³€κ²½λœλ‹€.") + @Test + void returnsOk_whenValidRequest() { + // arrange + String loginId = "testUser123"; + String currentPassword = "OldPass1!"; + String encodedPassword = bCryptPasswordEncoder.encode(currentPassword); + User savedUser = UserFixture.builder() + .loginId(loginId) + .password(encodedPassword) + .build(); + userJpaRepository.save(savedUser); + + String newPassword = "NewPass1!"; + UserV1Dto.UpdatePasswordRequest request = new UserV1Dto.UpdatePasswordRequest(newPassword); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", currentPassword); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity requestEntity = new HttpEntity<>(request, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_UPDATE_PASSWORD, HttpMethod.PATCH, requestEntity, responseType); + + // assert + User updatedUser = userJpaRepository.findByLoginId(loginId).orElseThrow(); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(bCryptPasswordEncoder.matches(newPassword, updatedUser.getPassword())).isTrue() + ); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ‚¬μš©μžμ˜ λΉ„λ°€λ²ˆν˜Έλ₯Ό λ³€κ²½ν•˜λ©΄, 404 Not Foundλ₯Ό λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsNotFound_whenUserNotExists() { + // arrange + UserV1Dto.UpdatePasswordRequest request = new UserV1Dto.UpdatePasswordRequest("NewPass1!"); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nonExistingId"); + headers.set("X-Loopers-LoginPw", "OldPass1!"); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity requestEntity = new HttpEntity<>(request, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_UPDATE_PASSWORD, HttpMethod.PATCH, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμœΌλ©΄, 400 Bad Requestλ₯Ό λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsBadRequest_whenCurrentPasswordNotMatches() { + // arrange + String loginId = "testUser123"; + String currentPassword = "OldPass1!"; + String encodedPassword = bCryptPasswordEncoder.encode(currentPassword); + User savedUser = UserFixture.builder() + .loginId(loginId) + .password(encodedPassword) + .build(); + userJpaRepository.save(savedUser); + + UserV1Dto.UpdatePasswordRequest request = new UserV1Dto.UpdatePasswordRequest("NewPass1!"); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity requestEntity = new HttpEntity<>(request, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_UPDATE_PASSWORD, HttpMethod.PATCH, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•œ λΉ„λ°€λ²ˆν˜Έλ‘œ λ³€κ²½ν•˜λ©΄, 400 Bad Requestλ₯Ό λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsBadRequest_whenSamePassword() { + // arrange + String loginId = "testUser123"; + String currentPassword = "SamePass1!"; + String encodedPassword = bCryptPasswordEncoder.encode(currentPassword); + User savedUser = UserFixture.builder() + .loginId(loginId) + .password(encodedPassword) + .build(); + userJpaRepository.save(savedUser); + + UserV1Dto.UpdatePasswordRequest request = new UserV1Dto.UpdatePasswordRequest(currentPassword); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", currentPassword); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity requestEntity = new HttpEntity<>(request, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_UPDATE_PASSWORD, HttpMethod.PATCH, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +}