diff --git a/.gitignore b/.gitignore index 5a979af6..71d6abd4 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +### CLAUDE ### +.claude diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..5ec31561 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,41 @@ +## 개발 규칙 +### 진행 Workflow - 증강 코딩 +- **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. +- **중간 결과 보고** : AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입. +- **설계 주도권 유지** : AI 가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행. + +### 개발 Workflow - TDD (Red > Green > Refactor) +- 모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert) +#### 1. Red Phase : 실패하는 테스트 먼저 작성 +- 요구사항을 만족하는 기능 테스트 케이스 작성 +- 테스트 예시 +#### 2. Green Phase : 테스트를 통과하는 코드 작성 +- Red Phase 의 테스트가 모두 통과할 수 있는 코드 작성 +- 오버엔지니어링 금지 +#### 3. Refactor Phase : 불필요한 코드 제거 및 품질 개선 +- 불필요한 private 함수 지양, 객체지향적 코드 작성 +- unused import 제거 +- 성능 최적화 +- 모든 테스트 케이스가 통과해야 함 + +## 주의사항 +### 1. Never Do +- 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이요한 구현을 하지 말 것 +- null-safety 하지 않게 코드 작성하지 말 것 (Java 의 경우, Optional 을 활용할 것) +- println 코드 남기지 말 것 +- 당장 필요하지 않은 코드는 만들지 말 것 + +### 2. Recommendation +- 실제 API 를 호출해 확인하는 E2E 테스트 코드 작성 +- 재사용 가능한 객체 설계 +- 성능 최적화에 대한 대안 및 제안 +- 개발 완료된 API 의 경우, `.http/**.http` 에 분류해 작성 + +### 3. Priority +1. 실제 동작하는 해결책만 고려 +2. null-safety, thread-safety 고려 +3. 테스트 가능한 구조로 설계 +4. 기존 코드 패턴 분석 후 일관성 유지 + +## 소통규칙 +- 개발자의 의사를 확인해야 할 질문이 필요한 경우 사운드를 내서 알람을 준다 \ No newline at end of file diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..9ad4d8ea 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -6,6 +6,9 @@ dependencies { implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + // security + implementation("org.springframework.security:spring-security-crypto") + // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java new file mode 100644 index 00000000..ce7cf7f4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java @@ -0,0 +1,49 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.Objects; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BirthDate { + + private LocalDate value; + + private BirthDate(LocalDate value) { + this.value = value; + } + + public static BirthDate from(LocalDate value) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); + } + + if (value.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 오늘 이전 날짜만 가능합니다."); + } + + return new BirthDate(value); + } + + public LocalDate asLocalDate() { + return value; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + BirthDate birthDate = (BirthDate) o; + return Objects.equals(value, birthDate.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java new file mode 100644 index 00000000..710b903b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java @@ -0,0 +1,51 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Email { + + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + + private String value; + + private Email(String value) { + this.value = value; + } + + public static Email from(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); + } + + if (!EMAIL_PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); + } + + return new Email(value); + } + + public String asString() { + return value; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Email email = (Email) o; + return Objects.equals(value, email.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java new file mode 100644 index 00000000..0195600c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java @@ -0,0 +1,51 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LoginId { + + private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]{5,20}$"); + + private String value; + + private LoginId(String value) { + this.value = value; + } + + public static LoginId from(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인ID는 비어있을 수 없습니다."); + } + + if (!LOGIN_ID_PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인ID는 5~20자의 영문과 숫자로만 이루어져야 합니다."); + } + + return new LoginId(value); + } + + public String asString() { + return value; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + LoginId loginId = (LoginId) o; + return Objects.equals(value, loginId.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Name.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Name.java new file mode 100644 index 00000000..7208215a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Name.java @@ -0,0 +1,56 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Name { + + private static final Pattern NAME_PATTERN = Pattern.compile("^[가-힣]{2,6}$"); + + private String value; + + private Name(String value) { + this.value = value; + } + + + public static Name from(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + } + + if (!NAME_PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 한글 2~6자만 가능합니다."); + } + + return new Name(value); + } + + public String asString() { + return value; + } + + public String masked() { + return value.substring(0, value.length() - 1) + "*"; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Name name = (Name) o; + return Objects.equals(value, name.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java new file mode 100644 index 00000000..882ad17b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java @@ -0,0 +1,43 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.regex.Pattern; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Password { + + private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[a-zA-Z0-9`~!@#$%^&*|'\";:\\\\₩?]{8,16}$"); + + private String value; + + private Password(String value) { + this.value = value; + } + + public static Password of(String value, PasswordEncoder encoder) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다."); + } + + if (!PASSWORD_PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문대소문자, 숫자, 특수문자만 가능합니다."); + } + + return new Password(encoder.encode(value)); + } + + public String asString() { + return value; + } + + // Password는 저장 시 암호화 되므로 Password 간의 동일성/동등성은 PasswordEncoder 가 확인하는 것으로 한다. + public boolean matches(String raw, PasswordEncoder encoder) { + return encoder.matches(raw, value); + } +} 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..f19bd47c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java @@ -0,0 +1,8 @@ +package com.loopers.domain.user; + +public interface PasswordEncoder { + + String encode(String raw); + + boolean matches(String raw, String encoded); +} 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..284eb769 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,90 @@ +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.*; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "users") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "login_id", nullable = false, unique = true)) + private LoginId loginId; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "password", nullable = false)) + private Password password; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "name", nullable = false)) + private Name name; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "birth_date", nullable = false)) + private BirthDate birthDate; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "email", nullable = false)) + private Email email; + + private User(LoginId loginId, Password password, Name name, BirthDate birthDate, Email email) { + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public static User create(LoginId loginId, Password password, Name name, BirthDate birthDate, Email email) { + if (loginId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인ID는 필수입니다."); + } + + if (password == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); + } + + if (name == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + } + + if (birthDate == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); + } + + if (email == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다."); + } + + return new User(loginId, password, name, birthDate, email); + } + + public LoginId loginId() { + return loginId; + } + + public Password password() { + return password; + } + + public Name name() { + return name; + } + + public BirthDate birthDate() { + return birthDate; + } + + public Email email() { + return email; + } + + public void changePassword(Password newPassword) { + this.password = newPassword; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserInfo.java new file mode 100644 index 00000000..3f48de19 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserInfo.java @@ -0,0 +1,15 @@ +package com.loopers.domain.user; + +import java.time.LocalDate; + +public record UserInfo(String loginId, String maskedName, LocalDate birthDate, String email) { + + public static UserInfo from(User user) { + return new UserInfo( + user.loginId().asString(), + user.name().masked(), + user.birthDate().asLocalDate(), + user.email().asString() + ); + } +} 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..1a611c08 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.user; + +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +public interface UserRepository { + + User save(User user); + + Optional findByLoginId(LoginId 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..a1efe5d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,80 @@ +package com.loopers.domain.user; + +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; + +import java.time.LocalDate; + + +@Service +@RequiredArgsConstructor +public class UserService { + + private final PasswordEncoder passwordEncoder; + private final UserRepository userRepository; + + @Transactional + public LoginId signup(String loginId, String password, String name, LocalDate birthDate, String email) { + LoginId id = LoginId.from(loginId); + + if (userRepository.findByLoginId(id).isPresent()) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인ID입니다."); + } + + validatePasswordNotContainsBirthDate(password, birthDate); + + User user = User.create(id + , Password.of(password, passwordEncoder) + , Name.from(name) + , BirthDate.from(birthDate) + , Email.from(email)); + + userRepository.save(user); + + return user.loginId(); + } + + @Transactional(readOnly = true) + public UserInfo getMyInfo(String loginId, String password) { + User user = authenticate(loginId, password); + return UserInfo.from(user); + } + + @Transactional + public void changePassword(String loginId, String currentPassword, String newPassword) { + User user = authenticate(loginId, currentPassword); + + if (user.password().matches(newPassword, passwordEncoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호는 사용할 수 없습니다."); + } + + validatePasswordNotContainsBirthDate(newPassword, user.birthDate().asLocalDate()); + + user.changePassword(Password.of(newPassword, passwordEncoder)); + } + + private User authenticate(String loginId, String password) { + LoginId id = LoginId.from(loginId); + + User user = userRepository.findByLoginId(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 사용자입니다.")); + + if (!user.password().matches(password, passwordEncoder)) { + throw new CoreException(ErrorType.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."); + } + + return user; + } + + private void validatePasswordNotContainsBirthDate(String password, LocalDate birthDate) { + String birthDateWithDash = birthDate.toString(); + String birthDateWithoutDash = birthDateWithDash.replace("-", ""); + + if (password.contains(birthDateWithDash) || password.contains(birthDateWithoutDash)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 생년월일을 포함할 수 없습니다."); + } + } +} 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..0df1e80a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BcryptPasswordEncoder.java @@ -0,0 +1,21 @@ +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 raw) { + return encoder.encode(raw); + } + + @Override + public boolean matches(String raw, String encoded) { + return encoder.matches(raw, encoded); + } +} 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..eafbcba1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,11 @@ +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 findByLoginIdValue(String value); +} 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..dd6ebd3d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.LoginId; +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 User save(User user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findByLoginId(LoginId loginId) { + return userJpaRepository.findByLoginIdValue(loginId.asString()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java new file mode 100644 index 00000000..c6a89283 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User API", description = "회원 서비스 API") +public interface UserApiSpec { + + @Operation( + summary = "회원 가입", + description = "로그인ID, 비밀번호, 이름, 생년월일, 이메일을 입력해서 회원가입을 합니다." + ) + ApiResponse signup( + @Schema(name = "회원가입 요청", description = "회원가입 시 필요한 정보") + UserDto.SignupRequest signRequest + ); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java new file mode 100644 index 00000000..cb5e53cc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.domain.user.LoginId; +import com.loopers.domain.user.UserService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import com.loopers.domain.user.UserInfo; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController implements UserApiSpec { + + private final UserService userService; + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + @Override + public ApiResponse signup(@RequestBody UserDto.SignupRequest signRequest) { + LoginId loginId = userService.signup( + signRequest.loginId(), + signRequest.password(), + signRequest.name(), + signRequest.birthDate(), + signRequest.email() + ); + + UserDto.SignupResponse response = UserDto.SignupResponse.from(loginId); + return ApiResponse.success(response); + } + + @GetMapping("/me") + public ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password) { + UserInfo userInfo = userService.getMyInfo(loginId, password); + return ApiResponse.success(UserDto.MyInfoResponse.from(userInfo)); + } + + @PatchMapping("/me/password") + public ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String currentPassword, + @RequestBody UserDto.ChangePasswordRequest request) { + userService.changePassword(loginId, currentPassword, request.newPassword()); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java new file mode 100644 index 00000000..8c4563cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.domain.user.LoginId; +import com.loopers.domain.user.UserInfo; + +import java.time.LocalDate; + +public class UserDto { + + public record SignupRequest(String loginId, String password, String name, LocalDate birthDate, String email) { + } + + public record SignupResponse(String loginId) { + public static SignupResponse from(LoginId loginId) { + return new SignupResponse(loginId.asString()); + } + } + + public record ChangePasswordRequest(String newPassword) { + } + + public record MyInfoResponse(String loginId, String name, LocalDate birthDate, String email) { + public static MyInfoResponse from(UserInfo userInfo) { + return new MyInfoResponse( + userInfo.loginId(), + userInfo.maskedName(), + userInfo.birthDate(), + userInfo.email() + ); + } + } +} 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..1d3a7c9e 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,6 +11,7 @@ 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(), "존재하지 않는 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); private final HttpStatus status; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java new file mode 100644 index 00000000..7d33e056 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java @@ -0,0 +1,37 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +class BirthDateTest { + + @Test + void 생년월일이_미래면_BAD_REQUEST를_던진다() { + // given + LocalDate value = LocalDate.now().plusDays(1); + + // when-then + CoreException result = assertThrows(CoreException.class, () -> { + BirthDate.from(value); + }); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + void 생년월일이_오늘날짜_이전이면_정상적으로_생성() { + // given + LocalDate value = LocalDate.of(1996, 11, 22); + + // when + BirthDate birthDate = BirthDate.from(value); + + // then + assertThat(birthDate.asLocalDate()).isEqualTo(value); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java new file mode 100644 index 00000000..f8deb129 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java @@ -0,0 +1,34 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +public class EmailTest { + + @Test + void 이메일_형식을_따르지_않으면_BAD_REQUEST를_던진다() { + // given + String value = "testloopers.im"; + + // when-then + CoreException result = assertThrows(CoreException.class, () -> { + Email.from(value); + }); + } + + @Test + void 이메일_형식을_따르면_객체생성_성공() { + // given + String value = "test@loopers.im"; + + // when + Email email = Email.from(value); + + // then + assertThat(email.asString()).isEqualTo(value); + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java new file mode 100644 index 00000000..34f0eeb0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java @@ -0,0 +1,47 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +class LoginIdTest { + + @Test + void 로그인ID는_한글이_포함되면_BAD_REQUSET를_던진다() { + // given + String value = "루퍼스123loopers"; + + // when-then + CoreException result = assertThrows(CoreException.class, () -> { + LoginId.from(value); + }); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + void 로그인ID가_영문과_숫자로만_이루어지면_객체생성_성공() { + // given + String value = "loopers123"; + + // when + LoginId loginId = LoginId.from(value); + + // then + assertThat(loginId.asString()).isEqualTo(value); + } + + @ParameterizedTest + @ValueSource(strings = {"loop", "loopersloopersloopers"}) + void 로그인ID는_5자미만_20자초과이면_BAD_REQUEST를_던진다(String value) { + // when-then + CoreException result = assertThrows(CoreException.class, () -> { + LoginId.from(value); + }); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/NameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/NameTest.java new file mode 100644 index 00000000..c2109501 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/NameTest.java @@ -0,0 +1,57 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +class NameTest { + + @ParameterizedTest + @ValueSource(strings = {"김", "김가나다라마바"}) + void 이름이_2자미만_6자초과이면_BAD_REQUEST를_던진다(String value) { + // when-then + CoreException result = assertThrows(CoreException.class, () -> { + Name.from(value); + }); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @ParameterizedTest + @ValueSource(strings = {"김loop", "김123"}) + void 이름에_영어나_숫자가_포함되면_BAD_REQUEST를_던진다(String value) { + // when-then + CoreException result = assertThrows(CoreException.class, () -> { + Name.from(value); + }); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + void 이름이_한글_2자이상_6자이하이면_객체생성_성공() { + // given + String value = "루퍼스"; + + // when + Name name = Name.from(value); + + // then + assertThat(name.asString()).isEqualTo(value); + } + + @Test + void 마스킹하면_마지막_글자가_별표로_대체된다() { + // Arrange + Name name = Name.from("루퍼스"); + + // Act + String masked = name.masked(); + + // Assert + assertThat(masked).isEqualTo("루퍼*"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java new file mode 100644 index 00000000..73ad2e99 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -0,0 +1,56 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PasswordTest { + + @ParameterizedTest + @ValueSource(strings = {"1234567", "12345678123456789"}) + void 비밀번호가_8자미만_16자초과이면_BAD_REQUEST를_던진다(String value) { + // given + PasswordEncoder encoder = mock(PasswordEncoder.class); + + // when-then + CoreException result = assertThrows(CoreException.class, () -> { + Password.of(value, encoder); + }); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + void 비밀번호에_한글이_포함되면_BAD_REQUEST를_던진다() { + // given + String value = "비밀번호486"; + PasswordEncoder encoder = mock(PasswordEncoder.class); + + // when-then + CoreException result = assertThrows(CoreException.class, () -> { + Password.of(value, encoder); + }); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + @Test + void 유효한_비밀번호이면_암호화해서_객체생성_성공() { + // given + String value = "loopers123"; + PasswordEncoder encoder = mock(PasswordEncoder.class); + when(encoder.encode(value)).thenReturn("encrypted"); + + // when + Password password = Password.of(value, encoder); + + // then + assertThat(password.asString()).isEqualTo("encrypted"); + } +} 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..01109fd9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,167 @@ +package com.loopers.domain.user; + +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class UserServiceIntegrationTest { + + @Autowired + UserService userService; + + @Autowired + UserJpaRepository userJpaRepository; + + @Autowired + DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입 시,") + @Nested + class Signup { + + @Test + void 성공하면_LoginId를_반환하고_DB에_저장된다() { + // Arrange + String loginId = "loopers123"; + String password = "loopers123!@"; + String name = "루퍼스"; + LocalDate birthDate = LocalDate.of(1996, 11, 22); + String email = "test@loopers.im"; + + // Act + LoginId result = userService.signup(loginId, password, name, birthDate, email); + + // Assert + assertThat(result).isEqualTo(LoginId.from(loginId)); + + Optional savedUser = userJpaRepository.findByLoginIdValue(loginId); + assertThat(savedUser).isPresent(); + } + + @Test + void 중복된_로그인ID면_CONFLICT를_던진다() { + // Arrange + String loginId = "loopers123"; + userService.signup(loginId, "loopers123!@", "루퍼스", LocalDate.of(1996, 11, 22), "test@loopers.im"); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.signup(loginId, "otherPass123!", "다른이름", LocalDate.of(2000, 1, 1), "other@loopers.im"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("내 정보 조회 시,") + @Nested + class GetMyInfo { + + @Test + void 정상_인증이면_UserInfo를_반환한다() { + // Arrange + userService.signup("loopers123", "loopers123!@", "루퍼스", LocalDate.of(1996, 11, 22), "test@loopers.im"); + + // Act + UserInfo result = userService.getMyInfo("loopers123", "loopers123!@"); + + // Assert + assertThat(result.loginId()).isEqualTo("loopers123"); + assertThat(result.maskedName()).isEqualTo("루퍼*"); + assertThat(result.birthDate()).isEqualTo(LocalDate.of(1996, 11, 22)); + assertThat(result.email()).isEqualTo("test@loopers.im"); + } + + @Test + void 존재하지_않는_loginId면_NOT_FOUND를_던진다() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.getMyInfo("nonexist12", "loopers123!@"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + void 비밀번호가_불일치하면_UNAUTHORIZED를_던진다() { + // Arrange + userService.signup("loopers123", "loopers123!@", "루퍼스", LocalDate.of(1996, 11, 22), "test@loopers.im"); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.getMyInfo("loopers123", "wrongPass123!"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + } + + @DisplayName("비밀번호 변경 시,") + @Nested + class ChangePassword { + + @BeforeEach + void setUp() { + userService.signup("loopers123", "loopers123!@", "루퍼스", LocalDate.of(1996, 11, 22), "test@loopers.im"); + } + + @Test + void 정상_변경이면_새_비밀번호로_인증할_수_있다() { + // Act + userService.changePassword("loopers123", "loopers123!@", "newPass1234!"); + + // Assert - 새 비밀번호로 조회 성공 + UserInfo result = userService.getMyInfo("loopers123", "newPass1234!"); + assertThat(result.loginId()).isEqualTo("loopers123"); + } + + @Test + void 존재하지_않는_loginId면_NOT_FOUND를_던진다() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.changePassword("nonexist12", "loopers123!@", "newPass1234!"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + void 기존_비밀번호가_불일치하면_UNAUTHORIZED를_던진다() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.changePassword("loopers123", "wrongPass123!", "newPass1234!"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @Test + void 새_비밀번호가_기존과_동일하면_BAD_REQUEST를_던진다() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.changePassword("loopers123", "loopers123!@", "loopers123!@"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + void 새_비밀번호에_생년월일이_포함되면_BAD_REQUEST를_던진다() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.changePassword("loopers123", "loopers123!@", "ab19961122!"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} 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..c948fb72 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -0,0 +1,234 @@ +package com.loopers.domain.user; + +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.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class UserServiceTest { + + UserService userService; + PasswordEncoder passwordEncoder; + UserRepository userRepository; + + @BeforeEach + void beforeEach() { + passwordEncoder = mock(PasswordEncoder.class); + userRepository = mock(UserRepository.class); + userService = new UserService(passwordEncoder, userRepository); + } + + @DisplayName("회원가입 시, ") + @Nested + class Signup { + @Test + void 중복된_로그인ID면_CONFLICT를_던진다() { + // given + String loginId = "loopers123"; + String password = "loopers123!@"; + String name = "루퍼스"; + LocalDate birthDate = LocalDate.of(1996, 11, 22); + String email = "test@loopers.im"; + + when(userRepository.findByLoginId(LoginId.from(loginId))).thenReturn(Optional.of(mock(User.class))); + + + // when-then + CoreException result = assertThrows(CoreException.class, () -> { + userService.signup(loginId, password, name, birthDate, email); + }); + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @Test + void 생년월일이_비밀번호에_포함되면_BAD_REQUEST를_던진다() { + // given + String loginId = "loopers123"; + String password = "lo19961122@"; + String name = "루퍼스"; + LocalDate birthDate = LocalDate.of(1996, 11, 22); + String email = "test@loopers.im"; + + when(userRepository.findByLoginId(LoginId.from(loginId))).thenReturn(Optional.empty()); + + // when-then + CoreException result = assertThrows(CoreException.class, () -> { + userService.signup(loginId, password, name, birthDate, email); + }); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + void 정상적으로_되면_회원객체를_생성해서_반환() { + // given + String loginId = "loopers123"; + String password = "loopers123!@"; + String name = "루퍼스"; + LocalDate birthDate = LocalDate.of(1996, 11, 22); + String email = "test@loopers.im"; + + when(userRepository.findByLoginId(LoginId.from(loginId))).thenReturn(Optional.empty()); + + // when + LoginId returnLoginId = userService.signup(loginId, password, name, birthDate, email); + + // then + assertThat(returnLoginId).isEqualTo(LoginId.from(loginId)); + } + } + + @DisplayName("내 정보 조회 시, ") + @Nested + class GetMyInfo { + + @Test + void 존재하지_않는_loginId면_NOT_FOUND를_던진다() { + // Arrange + when(userRepository.findByLoginId(LoginId.from("loopers123"))).thenReturn(Optional.empty()); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.getMyInfo("loopers123", "loopers123!@"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + void 비밀번호가_불일치하면_UNAUTHORIZED를_던진다() { + // Arrange + User user = User.create( + LoginId.from("loopers123"), + Password.of("loopers123!@", passwordEncoder), + Name.from("루퍼스"), + BirthDate.from(LocalDate.of(1996, 11, 22)), + Email.from("test@loopers.im") + ); + when(userRepository.findByLoginId(LoginId.from("loopers123"))).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrongPass123", user.password().asString())).thenReturn(false); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.getMyInfo("loopers123", "wrongPass123"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @Test + void 정상_인증이면_UserInfo를_반환한다() { + // Arrange + when(passwordEncoder.encode("loopers123!@")).thenReturn("encoded"); + User user = User.create( + LoginId.from("loopers123"), + Password.of("loopers123!@", passwordEncoder), + Name.from("루퍼스"), + BirthDate.from(LocalDate.of(1996, 11, 22)), + Email.from("test@loopers.im") + ); + when(userRepository.findByLoginId(LoginId.from("loopers123"))).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("loopers123!@", "encoded")).thenReturn(true); + + // Act + UserInfo result = userService.getMyInfo("loopers123", "loopers123!@"); + + // Assert + assertThat(result.loginId()).isEqualTo("loopers123"); + assertThat(result.maskedName()).isEqualTo("루퍼*"); + assertThat(result.birthDate()).isEqualTo(LocalDate.of(1996, 11, 22)); + assertThat(result.email()).isEqualTo("test@loopers.im"); + } + } + + @DisplayName("비밀번호 수정 시, ") + @Nested + class ChangePassword { + + User user; + + @BeforeEach + void setUp() { + when(passwordEncoder.encode("loopers123!@")).thenReturn("encoded"); + user = User.create( + LoginId.from("loopers123"), + Password.of("loopers123!@", passwordEncoder), + Name.from("루퍼스"), + BirthDate.from(LocalDate.of(1996, 11, 22)), + Email.from("test@loopers.im") + ); + } + + @Test + void 존재하지_않는_loginId면_NOT_FOUND를_던진다() { + // Arrange + when(userRepository.findByLoginId(LoginId.from("loopers123"))).thenReturn(Optional.empty()); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.changePassword("loopers123", "loopers123!@", "newPass1234!"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + void 기존_비밀번호가_불일치하면_UNAUTHORIZED를_던진다() { + // Arrange + when(userRepository.findByLoginId(LoginId.from("loopers123"))).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrongPass123", "encoded")).thenReturn(false); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.changePassword("loopers123", "wrongPass123", "newPass1234!"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @Test + void 새_비밀번호가_기존과_동일하면_BAD_REQUEST를_던진다() { + // Arrange + when(userRepository.findByLoginId(LoginId.from("loopers123"))).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("loopers123!@", "encoded")).thenReturn(true); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.changePassword("loopers123", "loopers123!@", "loopers123!@"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + void 새_비밀번호에_생년월일이_포함되면_BAD_REQUEST를_던진다() { + // Arrange + when(userRepository.findByLoginId(LoginId.from("loopers123"))).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("loopers123!@", "encoded")).thenReturn(true); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.changePassword("loopers123", "loopers123!@", "ab19961122!"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + void 정상_변경이면_비밀번호가_변경된다() { + // Arrange + when(userRepository.findByLoginId(LoginId.from("loopers123"))).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("loopers123!@", "encoded")).thenReturn(true); + when(passwordEncoder.encode("newPass1234!")).thenReturn("newEncoded"); + + // Act + userService.changePassword("loopers123", "loopers123!@", "newPass1234!"); + + // Assert + assertThat(user.password().asString()).isEqualTo("newEncoded"); + } + } + +} 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..87714c93 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,58 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class UserTest { + + @Test + void 로그인ID_비밀번호_이름_생년월일_이메일이_모두_주어지면_정상적으로_생성() { + // given + PasswordEncoder encoder = mock(PasswordEncoder.class); + when(encoder.encode("loopers123")).thenReturn("encrypted"); + when(encoder.matches("loopers123", "encrypted")).thenReturn(true); + + LoginId loginId = LoginId.from("loopers"); + Password password = Password.of("loopers123", encoder); + Name name = Name.from("루퍼스"); + BirthDate birthDate = BirthDate.from(LocalDate.of(1996, 11, 22)); + Email email = Email.from("tester@loopers.im"); + + // when + User user = User.create(loginId, password, name, birthDate, email); + + // then + assertAll( + () -> assertThat(user.loginId()).isEqualTo(loginId), + () -> assertThat(user.password().matches("loopers123", encoder)).isTrue(), + () -> assertThat(user.name()).isEqualTo(name), + () -> assertThat(user.birthDate()).isEqualTo(birthDate), + () -> assertThat(user.email()).isEqualTo(email) + ); + } + + @Test + void 로그인ID_비밀번호_이름_생년월일_이메일_중_하나라도_null이면_BAD_REQUEST를_던진다() { + // given + PasswordEncoder encoder = mock(PasswordEncoder.class); + + LoginId loginId = LoginId.from("loopers"); + Password password = Password.of("loopers123", encoder); + Name name = null; + BirthDate birthDate = BirthDate.from(LocalDate.of(1996, 11, 22)); + Email email = Email.from("tester@loopers.im"); + + // when-then + CoreException result = assertThrows(CoreException.class, () -> { + User.create(loginId, password, name, birthDate, email); + }); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/user/UserRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/user/UserRepositoryImplTest.java new file mode 100644 index 00000000..580fa10b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/user/UserRepositoryImplTest.java @@ -0,0 +1,52 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.*; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@SpringBootTest +@Transactional +class UserRepositoryImplTest { + + @Autowired + UserRepositoryImpl userRepository; + + @Test + void 존재하는_loginId로_조회하면_User를_반환() { + // given + PasswordEncoder encoder = mock(PasswordEncoder.class); + when(encoder.encode("loopers123")).thenReturn("encrypted"); + + LoginId loginId = LoginId.from("loopers"); + Password password = Password.of("loopers123", encoder); + Name name = Name.from("루퍼스"); + BirthDate birthDate = BirthDate.from(LocalDate.of(1996, 11, 22)); + Email email = Email.from("test@loopers.im"); + + User user = User.create(loginId, password, name, birthDate, email); + userRepository.save(user); + + // when + Optional findUser = userRepository.findByLoginId(loginId); + + // then + assertThat(findUser).isPresent(); + } + + @Test + void 존재하지_않는_loginId로_조회하면_빈_Optional_반환() { + // given-when + Optional findUser = userRepository.findByLoginId(LoginId.from("loop123")); + + // then + assertThat(findUser).isEmpty(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java new file mode 100644 index 00000000..9ef41037 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java @@ -0,0 +1,269 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +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.*; + +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 UserApiE2ETest { + + private static final String ENDPOINT = "/api/users"; + + @Autowired + TestRestTemplate testRestTemplate; + + @Autowired + DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/users") + @Nested + class Signup { + + @Test + void 정상_요청이면_201_CREATED와_loginId를_반환한다() { + // Arrange + UserDto.SignupRequest request = new UserDto.SignupRequest( + "loopers123", "loopers123!@", "루퍼스", + LocalDate.of(1996, 11, 22), "test@loopers.im" + ); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, jsonHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("loopers123") + ); + } + + @Test + void 중복된_로그인ID면_409_CONFLICT를_반환한다() { + // Arrange + UserDto.SignupRequest first = new UserDto.SignupRequest( + "loopers123", "loopers123!@", "루퍼스", + LocalDate.of(1996, 11, 22), "test@loopers.im" + ); + testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(first, jsonHeaders()), + new ParameterizedTypeReference>() {} + ); + + UserDto.SignupRequest duplicate = new UserDto.SignupRequest( + "loopers123", "otherPass123!", "다른이름", + LocalDate.of(2000, 1, 1), "other@loopers.im" + ); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(duplicate, jsonHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @Test + void 잘못된_입력값이면_400_BAD_REQUEST를_반환한다() { + // Arrange + UserDto.SignupRequest request = new UserDto.SignupRequest( + "ab", "loopers123!@", "루퍼스", + LocalDate.of(1996, 11, 22), "test@loopers.im" + ); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, jsonHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("GET /api/users/me") + @Nested + class GetMyInfo { + + @Test + void 정상_인증이면_200_OK와_유저정보를_반환한다() { + // Arrange + UserDto.SignupRequest signupRequest = new UserDto.SignupRequest( + "loopers123", "loopers123!@", "루퍼스", + LocalDate.of(1996, 11, 22), "test@loopers.im" + ); + testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(signupRequest, jsonHeaders()), + new ParameterizedTypeReference>() {} + ); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "loopers123"); + headers.set("X-Loopers-LoginPw", "loopers123!@"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me", HttpMethod.GET, new HttpEntity<>(null, headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("loopers123"), + () -> assertThat(response.getBody().data().name()).isEqualTo("루퍼*"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo(LocalDate.of(1996, 11, 22)), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@loopers.im") + ); + } + + @Test + void 존재하지_않는_loginId면_404_NOT_FOUND를_반환한다() { + // Arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nonexist12"); + headers.set("X-Loopers-LoginPw", "loopers123!@"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me", HttpMethod.GET, new HttpEntity<>(null, headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void 비밀번호가_불일치하면_401_UNAUTHORIZED를_반환한다() { + // Arrange + UserDto.SignupRequest signupRequest = new UserDto.SignupRequest( + "loopers123", "loopers123!@", "루퍼스", + LocalDate.of(1996, 11, 22), "test@loopers.im" + ); + testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(signupRequest, jsonHeaders()), + new ParameterizedTypeReference>() {} + ); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "loopers123"); + headers.set("X-Loopers-LoginPw", "wrongPass123!"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me", HttpMethod.GET, new HttpEntity<>(null, headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("PATCH /api/users/me/password") + @Nested + class ChangePassword { + + @Test + void 정상_변경이면_200_OK를_반환한다() { + // Arrange - 회원가입 + UserDto.SignupRequest signupRequest = new UserDto.SignupRequest( + "loopers123", "loopers123!@", "루퍼스", + LocalDate.of(1996, 11, 22), "test@loopers.im" + ); + testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(signupRequest, jsonHeaders()), + new ParameterizedTypeReference>() {} + ); + + HttpHeaders headers = jsonHeaders(); + headers.set("X-Loopers-LoginId", "loopers123"); + headers.set("X-Loopers-LoginPw", "loopers123!@"); + UserDto.ChangePasswordRequest request = new UserDto.ChangePasswordRequest("newPass1234!"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void 변경_후_새_비밀번호로_인증할_수_있다() { + // Arrange - 회원가입 후 비밀번호 변경 + UserDto.SignupRequest signupRequest = new UserDto.SignupRequest( + "loopers123", "loopers123!@", "루퍼스", + LocalDate.of(1996, 11, 22), "test@loopers.im" + ); + testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(signupRequest, jsonHeaders()), + new ParameterizedTypeReference>() {} + ); + + HttpHeaders changeHeaders = jsonHeaders(); + changeHeaders.set("X-Loopers-LoginId", "loopers123"); + changeHeaders.set("X-Loopers-LoginPw", "loopers123!@"); + testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + new HttpEntity<>(new UserDto.ChangePasswordRequest("newPass1234!"), changeHeaders), + new ParameterizedTypeReference>() {} + ); + + // Act - 새 비밀번호로 내 정보 조회 + HttpHeaders newHeaders = new HttpHeaders(); + newHeaders.set("X-Loopers-LoginId", "loopers123"); + newHeaders.set("X-Loopers-LoginPw", "newPass1234!"); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me", HttpMethod.GET, new HttpEntity<>(null, newHeaders), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("loopers123") + ); + } + } + + private HttpHeaders jsonHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserControllerTest.java new file mode 100644 index 00000000..4896ac67 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserControllerTest.java @@ -0,0 +1,110 @@ +package com.loopers.interfaces.api.user; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.user.LoginId; +import com.loopers.domain.user.UserService; +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.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; + +import com.loopers.domain.user.UserInfo; + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(UserController.class) +class UserControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @MockitoBean + UserService userService; + + @DisplayName("회원가입 시,") + @Nested + class Signup { + + @Test + void 성공하면_201_CREATED() throws Exception { + // given + UserDto.SignupRequest request = new UserDto.SignupRequest( + "looper123", + "password123", + "루퍼스", + LocalDate.of(1996, 11, 22), + "test@loopers.im" + ); + + when(userService.signup(request.loginId(), request.password(), request.name(), request.birthDate(), request.email())) + .thenReturn(LoginId.from("looper123")); + + String content = objectMapper.writeValueAsString(request); + + // when-then + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(content)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.loginId").value("looper123")); + } + } + + @DisplayName("내 정보 조회 시,") + @Nested + class GetMyInfo { + + @Test + void 성공하면_200_OK와_유저정보를_반환한다() throws Exception { + // Arrange + UserInfo userInfo = new UserInfo("loopers123", "루퍼*", LocalDate.of(1996, 11, 22), "test@loopers.im"); + when(userService.getMyInfo("loopers123", "loopers123!@")).thenReturn(userInfo); + + // Act & Assert + mockMvc.perform(get("/api/users/me") + .header("X-Loopers-LoginId", "loopers123") + .header("X-Loopers-LoginPw", "loopers123!@")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.loginId").value("loopers123")) + .andExpect(jsonPath("$.data.name").value("루퍼*")) + .andExpect(jsonPath("$.data.birthDate").value("1996-11-22")) + .andExpect(jsonPath("$.data.email").value("test@loopers.im")); + } + } + + @DisplayName("비밀번호 수정 시,") + @Nested + class ChangePassword { + + @Test + void 성공하면_200_OK를_반환한다() throws Exception { + // Arrange + UserDto.ChangePasswordRequest request = new UserDto.ChangePasswordRequest("newPass1234!"); + doNothing().when(userService).changePassword("loopers123", "loopers123!@", "newPass1234!"); + + String content = objectMapper.writeValueAsString(request); + + // Act & Assert + mockMvc.perform(patch("/api/users/me/password") + .header("X-Loopers-LoginId", "loopers123") + .header("X-Loopers-LoginPw", "loopers123!@") + .contentType(MediaType.APPLICATION_JSON) + .content(content)) + .andExpect(status().isOk()); + } + } + +}