diff --git a/.claude/skills/requirements-analysis/SKILL.md b/.claude/skills/requirements-analysis/SKILL.md new file mode 100644 index 00000000..3485a8af --- /dev/null +++ b/.claude/skills/requirements-analysis/SKILL.md @@ -0,0 +1,77 @@ +--- +name: requirements-analysis +description: + 제공된 요구사항을 분석하고, 개발자와의 질문/대답을 통해 애매한 요구사항을 명확히 하여 정리합니다. + 모든 정리가 끝나면, 시퀀스 다이어그램, 클래스 다이어그램, ERD 등을 Mermaid 문법으로 작성한다. + 요구사항이 제공되었을 때, 코드를 작성하기 전 이를 명확히 하는 데에 사용합니다. +--- +요구사항을 분석할 때 반드시 다음 흐름을 따른다. +### 1️⃣ 요구사항을 그대로 믿지 말고, 문제 상황으로 다시 설명한다. +- 요구사항 문장을 정리하는 데서 끝내지 않는다. +- "무엇을 만들까?"가 아니라 "지금 어떤 문제가 있고, 그걸 왜 해결하려는가?" 로 재해석한다. +- 다음 관점을 분리해서 정리한다: + - 사용자 관점 + - 비즈니스 관점 + - 시스템 관점 +> 예시 +> "주문 실패 시 결제를 취소한다" → "결제 성공/실패와 주문 상태가 어긋나지 않도록 일관성을 유지하려는 문제" + +### 2️⃣ 애매한 요구사항을 숨기지 말고 드러낸다 +- 추측하거나 알아서 결정하지 않는다. +- 요구사항에서 결정되지 않은 부분을 명시적으로 나열한다. + **다음 유형의 질문을 반드시 포함한다:** +- 정책 질문: 기준 시점, 성공/실패 조건, 예외 처리 규칙 +- 경계 질문: 어디까지가 한 책임인가, 어디서 분리되는가 +- 확장 질문: 나중에 바뀔 가능성이 있는가 + +### 3️⃣ 요구사항 명확화를 위한 질문을 개발자 답변이 쉬운 형태로 제시한다 +- 질문은 우선순위를 가진다 (중요한 것부터). +- 선택지가 있는 경우, 옵션 + 영향도를 함께 제시한다. +> 형식 예시: +- 선택지 A: 하나의 트랜잭션으로 처리 → 구현 단순, 확장성 낮음 +- 선택지 B: 단계별 분리 → 구조 복잡, 확장/보상 처리 유리 + +### 4️⃣ 합의된 내용을 바탕으로 개념 모델부터 잡는다 +- 바로 코드나 기술 얘기로 들어가지 않는다. +- 먼저 다음을 정의한다: + - 액터 (사용자, 외부 시스템) + - 핵심 도메인 + - 보조/외부 시스템 +- 이 단계는 “구현”이 아니라 설계 사고 정렬이 목적이다. + +### 5️⃣ 다이어그램은 항상 이유 → 다이어그램 → 해석 순서로 제시한다 +**다이어그램을 그리기 전에 반드시 설명한다** +- 왜 이 다이어그램이 필요한지 +- 이 다이어그램으로 무엇을 검증하려는지 + +**다이어그램은 Mermaid 문법으로 작성한다** +사용 기준: +- **시퀀스 다이어그램** + - 책임 분리 + - 호출 순서 + - 트랜잭션 경계 확인 +- **클래스 다이어그램** + - 도메인 책임 + - 의존 방향 + - 응집도 확인 +- **ERD** + - 영속성 구조 + - 관계의 주인 + - 정규화 여부 + +### 6️⃣ 다이어그램을 던지고 끝내지 말고 읽는 법을 짚어준다 +- "이 구조에서 특히 봐야 할 포인트"를 2~3줄로 설명한다. +- 설계 의도가 드러나도록 해석을 붙인다. + +### 7️⃣ 설계의 잠재 리스크를 반드시 언급한다 +- 현재 설계가 가질 수 있는 위험을 숨기지 않는다. + - 트랜잭션 비대화 + - 도메인 간 결합도 증가 + - 정책 변경 시 영향 범위 확대 +- 해결책은 정답처럼 말하지 않고 선택지로 제시한다. + +### 톤 & 스타일 가이드 +- 강의처럼 설명하지 말고 설계 리뷰 톤을 유지한다 +- 정답이라고 제시하기보다, 다른 선택지가 있다면 이를 제공하도록 한다. +- 코드보다 의도, 책임, 경계를 더 중요하게 다룬다 +- 구현 전에 생각해야 할 것을 끌어내는 데 집중한다 \ No newline at end of file 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..6acd8606 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -11,6 +11,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + // security + implementation("org.springframework.security:spring-security-crypto") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") diff --git a/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java new file mode 100644 index 00000000..52b04d23 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/user/controller/UserController.java b/apps/commerce-api/src/main/java/com/loopers/user/controller/UserController.java new file mode 100644 index 00000000..b43fe448 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/user/controller/UserController.java @@ -0,0 +1,48 @@ +package com.loopers.user.controller; + +import com.loopers.user.domain.User; +import com.loopers.user.dto.ChangePasswordRequest; +import com.loopers.user.dto.CreateUserRequest; +import com.loopers.user.dto.CreateUserResponse; +import com.loopers.user.dto.GetMyInfoResponse; +import com.loopers.user.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserController { + + public static final String LOGIN_ID_HEADER = "X-Loopers-LoginId"; + public static final String LOGIN_PW_HEADER = "X-Loopers-LoginPw"; + + private final UserService userService; + + @PostMapping + public ResponseEntity createUser(@Valid @RequestBody CreateUserRequest request) { + User user = userService.createUser(request); + return ResponseEntity.status(HttpStatus.CREATED).body(CreateUserResponse.from(user)); + } + + @GetMapping("/me") + public ResponseEntity getMyInfo( + @RequestHeader(LOGIN_ID_HEADER) String loginId + ) { + GetMyInfoResponse response = userService.getMyInfo(loginId); + return ResponseEntity.ok(response); + } + + @PatchMapping("/password") + public ResponseEntity changePassword( + @RequestHeader(LOGIN_ID_HEADER) String loginId, + @RequestHeader(LOGIN_PW_HEADER) String currentPassword, + @Valid @RequestBody ChangePasswordRequest request + ) { + userService.changePassword(loginId, currentPassword, request.newPassword()); + return ResponseEntity.ok().build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/user/domain/User.java b/apps/commerce-api/src/main/java/com/loopers/user/domain/User.java new file mode 100644 index 00000000..e40a9334 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/user/domain/User.java @@ -0,0 +1,62 @@ +package com.loopers.user.domain; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import org.springframework.util.Assert; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Column(nullable = false, length = 10, unique = true) + private String loginId; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String birthDate; + + @Column(nullable = false) + private String email; + + @Builder + public User(String loginId, String password, String name, String birthDate, String email) { + validateRequired(loginId, password, name, birthDate, email); + + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + private void validateRequired(String loginId, String password, String name, String birthDate, String email) { + Assert.hasText(loginId, "loginId는 필수입니다"); + Assert.hasText(password, "password는 필수입니다"); + Assert.hasText(name, "name은 필수입니다"); + Assert.hasText(birthDate, "birthDate는 필수입니다"); + Assert.hasText(email, "email은 필수입니다"); + } + + public String getMaskedName() { + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } + + public void changePassword(String newPassword) { + Assert.hasText(newPassword, "새 비밀번호는 필수입니다"); + this.password = newPassword; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/user/dto/ChangePasswordRequest.java b/apps/commerce-api/src/main/java/com/loopers/user/dto/ChangePasswordRequest.java new file mode 100644 index 00000000..c4b500b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/user/dto/ChangePasswordRequest.java @@ -0,0 +1,9 @@ +package com.loopers.user.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ChangePasswordRequest( + @NotBlank + String newPassword +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/user/dto/CreateUserRequest.java b/apps/commerce-api/src/main/java/com/loopers/user/dto/CreateUserRequest.java new file mode 100644 index 00000000..2762a54f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/user/dto/CreateUserRequest.java @@ -0,0 +1,22 @@ +package com.loopers.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record CreateUserRequest( + @NotBlank + @Pattern(regexp = "^[a-zA-Z0-9]+$") + String loginId, + @NotBlank + String password, + @NotBlank + String name, + @NotBlank + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}") + String birthDate, + @Email + @NotBlank + String email +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/user/dto/CreateUserResponse.java b/apps/commerce-api/src/main/java/com/loopers/user/dto/CreateUserResponse.java new file mode 100644 index 00000000..30179061 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/user/dto/CreateUserResponse.java @@ -0,0 +1,21 @@ +package com.loopers.user.dto; + +import com.loopers.user.domain.User; + +public record CreateUserResponse( + Long id, + String loginId, + String name, + String email, + String birthDate +) { + public static CreateUserResponse from(User user) { + return new CreateUserResponse( + user.getId(), + user.getLoginId(), + user.getName(), + user.getEmail(), + user.getBirthDate() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/user/dto/GetMyInfoResponse.java b/apps/commerce-api/src/main/java/com/loopers/user/dto/GetMyInfoResponse.java new file mode 100644 index 00000000..71023def --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/user/dto/GetMyInfoResponse.java @@ -0,0 +1,19 @@ +package com.loopers.user.dto; + +import com.loopers.user.domain.User; + +public record GetMyInfoResponse( + String loginId, + String name, + String birthDate, + String email +) { + public static GetMyInfoResponse from(User user) { + return new GetMyInfoResponse( + user.getLoginId(), + user.getMaskedName(), + user.getBirthDate(), + user.getEmail() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/user/exception/DuplicateLoginIdException.java b/apps/commerce-api/src/main/java/com/loopers/user/exception/DuplicateLoginIdException.java new file mode 100644 index 00000000..3a837c04 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/user/exception/DuplicateLoginIdException.java @@ -0,0 +1,14 @@ +package com.loopers.user.exception; + +public class DuplicateLoginIdException extends RuntimeException { + + private static final String DEFAULT_MESSAGE = "이미 사용 중인 로그인 ID입니다."; + + public DuplicateLoginIdException() { + super(DEFAULT_MESSAGE); + } + + public DuplicateLoginIdException(String message) { + super(message); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/user/exception/GlobalExceptionHandler.java b/apps/commerce-api/src/main/java/com/loopers/user/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..c5ab863e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/user/exception/GlobalExceptionHandler.java @@ -0,0 +1,50 @@ +package com.loopers.user.exception; + +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +@Order(Ordered.HIGHEST_PRECEDENCE) +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .orElse("Validation failed"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("message", message)); + } + + @ExceptionHandler(DuplicateLoginIdException.class) + public ResponseEntity> handleDuplicateLoginId(DuplicateLoginIdException e) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("message", e.getMessage())); + } + + @ExceptionHandler(InvalidCredentialsException.class) + public ResponseEntity> handleInvalidCredentials(InvalidCredentialsException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("message", e.getMessage())); + } + + @ExceptionHandler(SamePasswordException.class) + public ResponseEntity> handleSamePassword(SamePasswordException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("message", e.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("message", e.getMessage())); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/user/exception/InvalidCredentialsException.java b/apps/commerce-api/src/main/java/com/loopers/user/exception/InvalidCredentialsException.java new file mode 100644 index 00000000..41773ec4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/user/exception/InvalidCredentialsException.java @@ -0,0 +1,8 @@ +package com.loopers.user.exception; + +public class InvalidCredentialsException extends RuntimeException { + + public InvalidCredentialsException() { + super("아이디 또는 비밀번호가 올바르지 않습니다."); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/user/exception/SamePasswordException.java b/apps/commerce-api/src/main/java/com/loopers/user/exception/SamePasswordException.java new file mode 100644 index 00000000..a6a6da7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/user/exception/SamePasswordException.java @@ -0,0 +1,14 @@ +package com.loopers.user.exception; + +public class SamePasswordException extends RuntimeException { + + private static final String DEFAULT_MESSAGE = "새 비밀번호는 현재 비밀번호와 달라야 합니다."; + + public SamePasswordException() { + super(DEFAULT_MESSAGE); + } + + public SamePasswordException(String message) { + super(message); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/user/repository/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/user/repository/UserRepository.java new file mode 100644 index 00000000..d307843f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/user/repository/UserRepository.java @@ -0,0 +1,15 @@ +package com.loopers.user.repository; + +import com.loopers.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + boolean existsByLoginId(String loginId); + + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/user/service/UserService.java b/apps/commerce-api/src/main/java/com/loopers/user/service/UserService.java new file mode 100644 index 00000000..3e427dde --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/user/service/UserService.java @@ -0,0 +1,77 @@ +package com.loopers.user.service; + +import com.loopers.user.domain.User; +import com.loopers.user.dto.CreateUserRequest; +import com.loopers.user.dto.GetMyInfoResponse; +import com.loopers.user.exception.DuplicateLoginIdException; +import com.loopers.user.exception.InvalidCredentialsException; +import com.loopers.user.exception.SamePasswordException; +import com.loopers.user.repository.UserRepository; +import com.loopers.user.validator.PasswordValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +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 User createUser(CreateUserRequest request) { + + if(userRepository.existsByLoginId(request.loginId())){ + throw new DuplicateLoginIdException(); + } + + //비밀번호 검증 + PasswordValidator.validate(request.password(), request.birthDate()); + + //비밀번호 암호화 + String encodedPassword = passwordEncoder.encode(request.password()); + + User user = new User( + request.loginId(), + encodedPassword, + request.name(), + request.birthDate(), + request.email() + ); + + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public GetMyInfoResponse getMyInfo(String loginId) { + User user = userRepository.findByLoginId(loginId) + .orElseThrow(InvalidCredentialsException::new); + + return GetMyInfoResponse.from(user); + } + + @Transactional + public void changePassword(String loginId, String currentPassword, String newPassword) { + User user = userRepository.findByLoginId(loginId) + .orElseThrow(InvalidCredentialsException::new); + + // 기존 비밀번호 확인 + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new InvalidCredentialsException(); + } + + // 새 비밀번호가 기존 비밀번호와 동일한지 확인 + if (passwordEncoder.matches(newPassword, user.getPassword())) { + throw new SamePasswordException(); + } + + // 새 비밀번호 규칙 검증 + PasswordValidator.validate(newPassword, user.getBirthDate()); + + // 비밀번호 암호화 후 저장 + String encodedNewPassword = passwordEncoder.encode(newPassword); + user.changePassword(encodedNewPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/user/validator/PasswordValidator.java b/apps/commerce-api/src/main/java/com/loopers/user/validator/PasswordValidator.java new file mode 100644 index 00000000..fb72595f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/user/validator/PasswordValidator.java @@ -0,0 +1,23 @@ +package com.loopers.user.validator; + +public class PasswordValidator { + + private PasswordValidator() { + } + + public static void validate(String password, String birthDate) { + if (password.length() < 8) { + throw new IllegalArgumentException("비밀번호는 8자 이상이어야 합니다."); + } + if (password.length() > 16) { + throw new IllegalArgumentException("비밀번호는 16자 이하여야 합니다."); + } + String pattern = "^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$"; + if (!password.matches(pattern)) { + throw new IllegalArgumentException("비밀번호는 영문 대소문자, 숫자, 특수문자만 허용됩니다."); + } + if (birthDate != null && password.contains(birthDate)) { + throw new IllegalArgumentException("비밀번호에 생년월일을 포함할 수 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/user/UserE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/user/UserE2ETest.java new file mode 100644 index 00000000..f201ee0d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/user/UserE2ETest.java @@ -0,0 +1,256 @@ +package com.loopers.user; + +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.user.dto.ChangePasswordRequest; +import com.loopers.user.dto.CreateUserRequest; +import com.loopers.user.dto.CreateUserResponse; +import com.loopers.user.dto.GetMyInfoResponse; +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.context.annotation.Import; +import org.springframework.http.*; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Transactional; + +import static com.loopers.user.controller.UserController.LOGIN_ID_HEADER; +import static com.loopers.user.controller.UserController.LOGIN_PW_HEADER; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +@Transactional +public class UserE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Test + void 회원가입_API_요청시_사용자가_생성되고_201_Created_반환() { + // given + CreateUserRequest request = new CreateUserRequest( + "testuser", + "Password1!", + "홍길동", + "1990-01-01", + "test@example.com" + ); + + //실제 HTTP 요청 + ResponseEntity response = restTemplate.postForEntity( + "/api/v1/users", + request, + CreateUserResponse.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + @Test + void 이미_존재하는_로그인ID로_회원가입시_409_Conflict_반환() { + // given + CreateUserRequest request = new CreateUserRequest( + "dupuser", + "Password1!", + "홍길동", + "1990-01-01", + "test@example.com" + ); + + // 첫 번째 회원가입 (성공) + restTemplate.postForEntity("/api/v1/users", request, CreateUserResponse.class); + + // when - 동일한 loginId로 두 번째 회원가입 시도 + ResponseEntity response = restTemplate.postForEntity( + "/api/v1/users", + request, + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void 내_정보_조회_API_요청시_마스킹된_이름이_포함된_사용자_정보와_200_OK_반환() { + // given - 사용자 생성 + String loginId = "myinfouser"; + CreateUserRequest createRequest = new CreateUserRequest( + loginId, "Password1!", "홍길동", "1990-01-01", "test@example.com" + ); + restTemplate.postForEntity("/api/v1/users", createRequest, CreateUserResponse.class); + + // when - 내 정보 조회 + HttpHeaders headers = new HttpHeaders(); + headers.set(LOGIN_ID_HEADER, loginId); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/users/me", + HttpMethod.GET, + entity, + GetMyInfoResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().loginId()).isEqualTo(loginId); + assertThat(response.getBody().name()).isEqualTo("홍길*"); + assertThat(response.getBody().birthDate()).isEqualTo("1990-01-01"); + assertThat(response.getBody().email()).isEqualTo("test@example.com"); + } + + @Test + void 존재하지_않는_로그인ID로_내_정보_조회시_401_Unauthorized_반환() { + // given - 존재하지 않는 로그인 ID + HttpHeaders headers = new HttpHeaders(); + headers.set(LOGIN_ID_HEADER, "nonexistentuser"); + HttpEntity entity = new HttpEntity<>(headers); + + // when + ResponseEntity response = restTemplate.exchange( + "/api/v1/users/me", + HttpMethod.GET, + entity, + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void 비밀번호_변경_API_요청시_200_OK_반환() { + // given - 사용자 생성 + String loginId = "pwchg" + (System.currentTimeMillis() % 10000); + String currentPassword = "Password1!"; + String newPassword = "NewPassword2@"; + + CreateUserRequest createRequest = new CreateUserRequest( + loginId, currentPassword, "홍길동", "1990-01-01", "test@example.com" + ); + ResponseEntity createResponse = restTemplate.postForEntity("/api/v1/users", createRequest, CreateUserResponse.class); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + // when - 비밀번호 변경 + HttpHeaders headers = new HttpHeaders(); + headers.set(LOGIN_ID_HEADER, loginId); + headers.set(LOGIN_PW_HEADER, currentPassword); + headers.setContentType(MediaType.APPLICATION_JSON); + + ChangePasswordRequest changeRequest = new ChangePasswordRequest(newPassword); + HttpEntity entity = new HttpEntity<>(changeRequest, headers); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/users/password", + HttpMethod.PATCH, + entity, + Void.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void 비밀번호_변경시_기존_비밀번호가_일치하지_않으면_401_Unauthorized_반환() { + // given - 사용자 생성 + String loginId = "pwfail" + (System.currentTimeMillis() % 1000); + String currentPassword = "Password1!"; + String wrongPassword = "WrongPassword!"; + String newPassword = "NewPassword2@"; + + CreateUserRequest createRequest = new CreateUserRequest( + loginId, currentPassword, "홍길동", "1990-01-01", "test@example.com" + ); + ResponseEntity createResponse = restTemplate.postForEntity("/api/v1/users", createRequest, CreateUserResponse.class); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + // when - 잘못된 비밀번호로 변경 시도 + HttpHeaders headers = new HttpHeaders(); + headers.set(LOGIN_ID_HEADER, loginId); + headers.set(LOGIN_PW_HEADER, wrongPassword); + headers.setContentType(MediaType.APPLICATION_JSON); + + ChangePasswordRequest changeRequest = new ChangePasswordRequest(newPassword); + HttpEntity entity = new HttpEntity<>(changeRequest, headers); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/users/password", + HttpMethod.PATCH, + entity, + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void 비밀번호_변경시_새_비밀번호가_기존과_동일하면_400_Bad_Request_반환() { + // given - 사용자 생성 + String loginId = "pwsame" + (System.currentTimeMillis() % 1000); + String currentPassword = "Password1!"; + + CreateUserRequest createRequest = new CreateUserRequest( + loginId, currentPassword, "홍길동", "1990-01-01", "test@example.com" + ); + ResponseEntity createResponse = restTemplate.postForEntity("/api/v1/users", createRequest, CreateUserResponse.class); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + // when - 동일한 비밀번호로 변경 시도 + HttpHeaders headers = new HttpHeaders(); + headers.set(LOGIN_ID_HEADER, loginId); + headers.set(LOGIN_PW_HEADER, currentPassword); + headers.setContentType(MediaType.APPLICATION_JSON); + + ChangePasswordRequest changeRequest = new ChangePasswordRequest(currentPassword); + HttpEntity entity = new HttpEntity<>(changeRequest, headers); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/users/password", + HttpMethod.PATCH, + entity, + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 비밀번호_변경시_8자_미만_규칙_위반하면_400_Bad_Request_반환() { + // given - 사용자 생성 + String loginId = "pwrule" + (System.currentTimeMillis() % 1000); + String currentPassword = "Password1!"; + String invalidPassword = "short"; // 8자 미만 + + CreateUserRequest createRequest = new CreateUserRequest( + loginId, currentPassword, "홍길동", "1990-01-01", "test@example.com" + ); + ResponseEntity createResponse = restTemplate.postForEntity("/api/v1/users", createRequest, CreateUserResponse.class); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + // when - 규칙 위반 비밀번호로 변경 시도 + HttpHeaders headers = new HttpHeaders(); + headers.set(LOGIN_ID_HEADER, loginId); + headers.set(LOGIN_PW_HEADER, currentPassword); + headers.setContentType(MediaType.APPLICATION_JSON); + + ChangePasswordRequest changeRequest = new ChangePasswordRequest(invalidPassword); + HttpEntity entity = new HttpEntity<>(changeRequest, headers); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/users/password", + HttpMethod.PATCH, + entity, + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/user/controller/CreateUserRequestValidationTest.java b/apps/commerce-api/src/test/java/com/loopers/user/controller/CreateUserRequestValidationTest.java new file mode 100644 index 00000000..d861bce2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/user/controller/CreateUserRequestValidationTest.java @@ -0,0 +1,116 @@ +package com.loopers.user.controller; + +import com.loopers.user.dto.CreateUserRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Pattern; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Set; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CreateUserRequestValidationTest { + + private Validator validator; + + @BeforeEach + void setUp() { + validator = Validation.buildDefaultValidatorFactory().getValidator(); + } + + @ParameterizedTest + @MethodSource("필수값_누락_케이스") + void 회원가입시_필수정보를_입력하지_않으면_실패한다(CreateUserRequest request, String expectedField) { + //given + + //when + Set> violations = validator.validate(request); + + //then + assertThat(violations).hasSize(1); + assertThat(violations.iterator().next().getPropertyPath().toString()).isEqualTo(expectedField); + } + + static Stream 필수값_누락_케이스() { + return Stream.of( + Arguments.of(new CreateUserRequest(null, "pw", "name", "1990-01-01", "a@a.com"), "loginId"), + Arguments.of(new CreateUserRequest("test", null, "name", "1990-01-01", "a@a.com"), "password"), + Arguments.of(new CreateUserRequest("test", "pw", null, "1990-01-01", "a@a.com"), "name"), + Arguments.of(new CreateUserRequest("test", "pw", "name", null, "a@a.com"), "birthDate"), + Arguments.of(new CreateUserRequest("test", "pw", "name", "1990-01-01", null), "email") + ); + } + + @Test + void 이메일_형식_불일치_시_실패() { + //given + String id = "test"; + String password = "pw"; + String name = "name"; + String birthDate = "1990-01-01"; + String email = "test123"; + CreateUserRequest request = new CreateUserRequest(id, password, name, birthDate, email); + + //when + Set> violations = validator.validate(request); + + //then + assertThat(violations).hasSize(1); + ConstraintViolation violation = violations.iterator().next(); + assertThat(violation.getPropertyPath().toString()).isEqualTo("email"); + assertThat(violation.getConstraintDescriptor() + .getAnnotation() + .annotationType()) + .isEqualTo(Email.class); + } + + @Test + void 생년월일_형식_불일치_시_실패() { + //given + String id = "test"; + String password = "pw"; + String name = "name"; + String birthDate = "19900427"; + String email = "test123@test.com"; + CreateUserRequest request = new CreateUserRequest(id, password, name, birthDate, email); + + //when + Set> violations = validator.validate(request); + + //then + assertThat(violations).hasSize(1); + ConstraintViolation violation = violations.iterator().next(); + assertThat(violation.getPropertyPath().toString()).isEqualTo("birthDate"); + } + + @Test + void 로그인ID에_영문_숫자_외_문자_포함_시_실패() { + //given + String id = "test@123"; + String password = "pw"; + String name = "name"; + String birthDate = "1990-01-01"; + String email = "test@test.com"; + CreateUserRequest request = new CreateUserRequest(id, password, name, birthDate, email); + + //when + Set> violations = validator.validate(request); + + //then + assertThat(violations).hasSize(1); + ConstraintViolation violation = violations.iterator().next(); + assertThat(violation.getPropertyPath().toString()).isEqualTo("loginId"); + assertThat(violation.getConstraintDescriptor() + .getAnnotation() + .annotationType()) + .isEqualTo(Pattern.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/user/controller/UserControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/user/controller/UserControllerTest.java new file mode 100644 index 00000000..a82ce7c4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/user/controller/UserControllerTest.java @@ -0,0 +1,126 @@ +package com.loopers.user.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.user.domain.User; +import com.loopers.user.dto.CreateUserRequest; +import com.loopers.user.exception.GlobalExceptionHandler; +import com.loopers.user.service.UserService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.stream.Stream; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(UserController.class) +@Import(GlobalExceptionHandler.class) +public class UserControllerTest { + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @MockitoBean + UserService userService; + + @Test + void 회원가입_성공_시_201_반환() throws Exception { + //given + CreateUserRequest request = new CreateUserRequest( + "testId", "password123!", "김준영", "1990-04-27", "test@test.com" + ); + + User user = User.builder() + .loginId("testId") + .password("encoded") + .name("김준영") + .birthDate("1990-04-27") + .email("test@test.com") + .build(); + + given(userService.createUser(any(CreateUserRequest.class))) + .willReturn(user); + + //when + ResultActions result = mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + //then + result.andExpect(status().isCreated()); + } + + @ParameterizedTest(name = "{1} 누락 시 400 반환") + @MethodSource("필수값_누락_케이스") + void 필수값_누락_시_400_Bad_Request_반환(CreateUserRequest request, String fieldName) throws Exception { + //given + + //when + ResultActions result = mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + //then + result.andExpect(status().isBadRequest()); + } + + static Stream 필수값_누락_케이스() { + return Stream.of( + Arguments.of(new CreateUserRequest(null, "pw", "name", "1990-01-01", "a@a.com"), "loginId"), + Arguments.of(new CreateUserRequest("test", null, "name", "1990-01-01", "a@a.com"), "password"), + Arguments.of(new CreateUserRequest("test", "pw", null, "1990-01-01", "a@a.com"), "name"), + Arguments.of(new CreateUserRequest("test", "pw", "name", null, "a@a.com"), "birthDate"), + Arguments.of(new CreateUserRequest("test", "pw", "name", "1990-01-01", null), "email") + ); + } + + @Test + void 이메일_형식_오류_시_400_Bad_Request_반환() throws Exception { + //given + CreateUserRequest request = new CreateUserRequest( + "testId", "password123!", "김준영", "1990-04-27", "testtest.com" + ); + + //when + ResultActions result = mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + //then + result.andDo(print()).andExpect(status().isBadRequest()); + } + + @Test + void 생년월일_형식_오류_시_400_Bad_Request_반환() throws Exception { + //given + CreateUserRequest request = new CreateUserRequest( + "testId", "password123!", "김준영", "1990-0427", "test@test.com" + ); + + //when + ResultActions result = mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + //then + result.andExpect(status().isBadRequest()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/user/domain/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/user/domain/UserTest.java new file mode 100644 index 00000000..29f6c521 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/user/domain/UserTest.java @@ -0,0 +1,81 @@ +package com.loopers.user.domain; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserTest { + + @Test + void 이름의_마지막_글자가_마스킹된다() { + // given + User user = User.builder() + .loginId("testId") + .password("password123!") + .name("홍길동") + .birthDate("1990-01-01") + .email("test@test.com") + .build(); + + // when + String maskedName = user.getMaskedName(); + + // then + assertThat(maskedName).isEqualTo("홍길*"); + } + + @Test + void 한_글자_이름은_마스킹_문자로_반환된다() { + // given + User user = User.builder() + .loginId("testId") + .password("password123!") + .name("김") + .birthDate("1990-01-01") + .email("test@test.com") + .build(); + + // when + String maskedName = user.getMaskedName(); + + // then + assertThat(maskedName).isEqualTo("*"); + } + + @Test + void 두_글자_이름의_마지막_글자가_마스킹된다() { + // given + User user = User.builder() + .loginId("testId") + .password("password123!") + .name("이순") + .birthDate("1990-01-01") + .email("test@test.com") + .build(); + + // when + String maskedName = user.getMaskedName(); + + // then + assertThat(maskedName).isEqualTo("이*"); + } + + @Test + void changePassword로_비밀번호가_변경된다() { + // given + User user = User.builder() + .loginId("testId") + .password("oldPassword123!") + .name("홍길동") + .birthDate("1990-01-01") + .email("test@test.com") + .build(); + String newPassword = "newPassword456!"; + + // when + user.changePassword(newPassword); + + // then + assertThat(user.getPassword()).isEqualTo(newPassword); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/user/service/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/user/service/UserServiceIntegrationTest.java new file mode 100644 index 00000000..8e5721a2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/user/service/UserServiceIntegrationTest.java @@ -0,0 +1,155 @@ +package com.loopers.user.service; + +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.user.domain.User; +import com.loopers.user.dto.CreateUserRequest; +import com.loopers.user.dto.GetMyInfoResponse; +import com.loopers.user.exception.DuplicateLoginIdException; +import com.loopers.user.exception.InvalidCredentialsException; +import com.loopers.user.exception.SamePasswordException; +import org.springframework.security.crypto.password.PasswordEncoder; +import com.loopers.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@Transactional +public class UserServiceIntegrationTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserService userService; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Test + void 회원_가입시_User_저장이_수행된다() { + //given + CreateUserRequest request = new CreateUserRequest( + "testuser", "password123!", "홍길동", "1990-04-27", "test@test.com" + ); + + //when + User savedUser = userService.createUser(request); + + //then + User foundUser = userRepository.findById(savedUser.getId()).orElseThrow(); + assertThat(foundUser.getLoginId()).isEqualTo(request.loginId()); + assertThat(foundUser.getName()).isEqualTo(request.name()); + assertThat(foundUser.getEmail()).isEqualTo(request.email()); + } + + @Test + void 이미_가입된_ID로_회원가입_시도_시_DuplicateLoginIdException이_발생한다() { + //given + CreateUserRequest request = new CreateUserRequest( + "testuser", "password123!", "홍길동", "1990-04-27", "test@test.com" + ); + //testuser 라는 ID로 가입 + userService.createUser(request); + + //when + //동일한 아이디로 가입하는 경우 + CreateUserRequest duplicateRequest = new CreateUserRequest( + "testuser", "password456!", "김철수", "1995-01-01", "other@test.com" + ); + Throwable thrown = catchThrowable(() -> userService.createUser(duplicateRequest)); + + //then + assertThat(thrown).isInstanceOf(DuplicateLoginIdException.class); + } + + @Test + void DB에_저장된_사용자_정보를_정상적으로_조회한다() { + // given + String loginId = "testuser"; + CreateUserRequest request = new CreateUserRequest( + loginId, "password123!", "홍길동", "1990-01-01", "test@test.com" + ); + userService.createUser(request); + + // when + GetMyInfoResponse response = userService.getMyInfo(loginId); + + // then + assertThat(response.loginId()).isEqualTo(loginId); + assertThat(response.name()).isEqualTo("홍길*"); + assertThat(response.birthDate()).isEqualTo("1990-01-01"); + assertThat(response.email()).isEqualTo("test@test.com"); + } + + @Test + void 존재하지_않는_로그인ID로_조회시_InvalidCredentialsException이_발생한다() { + // given + String nonExistentLoginId = "nonexistent"; + + // when & then + assertThatThrownBy(() -> userService.getMyInfo(nonExistentLoginId)) + .isInstanceOf(InvalidCredentialsException.class); + } + + @Test + void DB에_저장된_사용자의_비밀번호가_정상적으로_변경된다() { + // given + String loginId = "testuser"; + String currentPassword = "password123!"; + String newPassword = "newPassword456!"; + + CreateUserRequest request = new CreateUserRequest( + loginId, currentPassword, "홍길동", "1990-01-01", "test@test.com" + ); + userService.createUser(request); + + // when + userService.changePassword(loginId, currentPassword, newPassword); + + // then + User updatedUser = userRepository.findByLoginId(loginId).orElseThrow(); + assertThat(passwordEncoder.matches(newPassword, updatedUser.getPassword())).isTrue(); + } + + @Test + void 비밀번호_변경시_기존_비밀번호가_일치하지_않으면_InvalidCredentialsException이_발생한다() { + // given + String loginId = "testuser"; + String currentPassword = "password123!"; + String wrongPassword = "wrongPassword!"; + String newPassword = "newPassword456!"; + + CreateUserRequest request = new CreateUserRequest( + loginId, currentPassword, "홍길동", "1990-01-01", "test@test.com" + ); + userService.createUser(request); + + // when & then + assertThatThrownBy(() -> userService.changePassword(loginId, wrongPassword, newPassword)) + .isInstanceOf(InvalidCredentialsException.class); + } + + @Test + void 비밀번호_변경시_새_비밀번호가_기존과_동일하면_SamePasswordException이_발생한다() { + // given + String loginId = "testuser"; + String currentPassword = "password123!"; + + CreateUserRequest request = new CreateUserRequest( + loginId, currentPassword, "홍길동", "1990-01-01", "test@test.com" + ); + userService.createUser(request); + + // when & then + assertThatThrownBy(() -> userService.changePassword(loginId, currentPassword, currentPassword)) + .isInstanceOf(SamePasswordException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/user/service/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/user/service/UserServiceTest.java new file mode 100644 index 00000000..c0e06360 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/user/service/UserServiceTest.java @@ -0,0 +1,219 @@ +package com.loopers.user.service; + +import com.loopers.user.domain.User; +import com.loopers.user.dto.CreateUserRequest; +import com.loopers.user.dto.GetMyInfoResponse; +import com.loopers.user.exception.InvalidCredentialsException; +import com.loopers.user.exception.SamePasswordException; +import com.loopers.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +public class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private UserService userService; + + @Test + void 정상_입력시_회원가입_성공() { + //given + CreateUserRequest request = new CreateUserRequest( + "testId", "password123!", "김준영", "1990-04-27", "test@test.com" + ); + + given(passwordEncoder.encode(request.password())).willReturn("encodedPassword"); + given(userRepository.save(any(User.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + //when + User user = userService.createUser(request); + + //then + assertThat(user.getLoginId()).isEqualTo(request.loginId()); + verify(userRepository).save(any(User.class)); + } + + @Test + void 비밀번호가_암호화되어_저장된다() { + //given + String rawPassword = "password123!"; + String encodedPassword = "encoded_password_hash"; + CreateUserRequest request = new CreateUserRequest( + "testId", rawPassword, "test", "1990-04-27", "test@test.com" + ); + + //password를 암호화한다. + given(passwordEncoder.encode(rawPassword)).willReturn(encodedPassword); + //사용자를 저장소에 저장한다. + given(userRepository.save(any(User.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + //when + //회원가입을 진행했을 때 + User user = userService.createUser(request); + + //then + //비밀번호가 암호화되었는지 확인한다. + assertThat(user.getPassword()).isEqualTo(encodedPassword); + //암호화 로직 호출했는지 확인한다. + verify(passwordEncoder).encode(rawPassword); + } + + @Test + void 유효한_로그인ID로_내_정보를_조회한다() { + // given + String loginId = "testId"; + User user = User.builder() + .loginId(loginId) + .password("encodedPassword") + .name("홍길동") + .birthDate("1990-01-01") + .email("test@test.com") + .build(); + + given(userRepository.findByLoginId(loginId)).willReturn(Optional.of(user)); + + // when + GetMyInfoResponse response = userService.getMyInfo(loginId); + + // then + assertThat(response.loginId()).isEqualTo(loginId); + assertThat(response.name()).isEqualTo("홍길*"); + assertThat(response.birthDate()).isEqualTo("1990-01-01"); + assertThat(response.email()).isEqualTo("test@test.com"); + } + + @Test + void 존재하지_않는_로그인ID로_조회시_InvalidCredentialsException이_발생한다() { + // given + String loginId = "nonExistentId"; + + given(userRepository.findByLoginId(loginId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userService.getMyInfo(loginId)) + .isInstanceOf(InvalidCredentialsException.class); + } + + @Test + void 올바른_기존_비밀번호로_비밀번호_변경에_성공한다() { + // given + String loginId = "testId"; + String currentPassword = "oldPassword123!"; + String newPassword = "newPassword456!"; + String encodedCurrentPassword = "encodedOldPassword"; + String encodedNewPassword = "encodedNewPassword"; + + User user = User.builder() + .loginId(loginId) + .password(encodedCurrentPassword) + .name("홍길동") + .birthDate("1990-01-01") + .email("test@test.com") + .build(); + + given(userRepository.findByLoginId(loginId)).willReturn(Optional.of(user)); + given(passwordEncoder.matches(currentPassword, encodedCurrentPassword)).willReturn(true); + given(passwordEncoder.matches(newPassword, encodedCurrentPassword)).willReturn(false); + given(passwordEncoder.encode(newPassword)).willReturn(encodedNewPassword); + + // when + userService.changePassword(loginId, currentPassword, newPassword); + + // then + assertThat(user.getPassword()).isEqualTo(encodedNewPassword); + } + + @Test + void 기존_비밀번호가_일치하지_않으면_InvalidCredentialsException이_발생한다() { + // given + String loginId = "testId"; + String wrongPassword = "wrongPassword!"; + String newPassword = "newPassword456!"; + String encodedSavedPassword = "encodedPassword"; + + User user = User.builder() + .loginId(loginId) + .password(encodedSavedPassword) + .name("홍길동") + .birthDate("1990-01-01") + .email("test@test.com") + .build(); + + given(userRepository.findByLoginId(loginId)).willReturn(Optional.of(user)); + given(passwordEncoder.matches(wrongPassword, encodedSavedPassword)).willReturn(false); + + // when & then + assertThatThrownBy(() -> userService.changePassword(loginId, wrongPassword, newPassword)) + .isInstanceOf(InvalidCredentialsException.class); + } + + @Test + void 새_비밀번호가_기존_비밀번호와_동일하면_SamePasswordException이_발생한다() { + // given + String loginId = "testId"; + String currentPassword = "samePassword123!"; + String newPassword = "samePassword123!"; + String encodedSavedPassword = "encodedPassword"; + + User user = User.builder() + .loginId(loginId) + .password(encodedSavedPassword) + .name("홍길동") + .birthDate("1990-01-01") + .email("test@test.com") + .build(); + + given(userRepository.findByLoginId(loginId)).willReturn(Optional.of(user)); + given(passwordEncoder.matches(currentPassword, encodedSavedPassword)).willReturn(true); + given(passwordEncoder.matches(newPassword, encodedSavedPassword)).willReturn(true); + + // when & then + assertThatThrownBy(() -> userService.changePassword(loginId, currentPassword, newPassword)) + .isInstanceOf(SamePasswordException.class); + } + + @Test + void 새_비밀번호가_규칙에_맞지_않으면_IllegalArgumentException이_발생한다() { + // given + String loginId = "testId"; + String currentPassword = "oldPassword123!"; + String invalidNewPassword = "short"; // 8자 미만 + String encodedSavedPassword = "encodedPassword"; + + User user = User.builder() + .loginId(loginId) + .password(encodedSavedPassword) + .name("홍길동") + .birthDate("1990-01-01") + .email("test@test.com") + .build(); + + given(userRepository.findByLoginId(loginId)).willReturn(Optional.of(user)); + given(passwordEncoder.matches(currentPassword, encodedSavedPassword)).willReturn(true); + given(passwordEncoder.matches(invalidNewPassword, encodedSavedPassword)).willReturn(false); + + // when & then + assertThatThrownBy(() -> userService.changePassword(loginId, currentPassword, invalidNewPassword)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/user/validator/PasswordValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/user/validator/PasswordValidatorTest.java new file mode 100644 index 00000000..682fad43 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/user/validator/PasswordValidatorTest.java @@ -0,0 +1,61 @@ +package com.loopers.user.validator; + +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.assertj.core.api.Assertions.catchThrowable; + +public class PasswordValidatorTest { + + + @Test + void 비밀번호가_8자_미만이면_IllegalArgumentException이_발생한다() { + //given + String password = "1234"; + + //when + Throwable thrown = catchThrowable(() -> PasswordValidator.validate(password, null)); + + //then + assertThat(thrown).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 비밀번호가_16자_초과하면_IllegalArgumentException이_발생한다() { + //given + String password = "12345678901234567"; + + //when + Throwable thrown = catchThrowable(() -> PasswordValidator.validate(password, null)); + + //then + assertThat(thrown).isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"Password1!한글", "Password1!😀", "Password 1!"}) + void 비밀번호에_허용되지_않는_문자_포함시_IllegalArgumentException이_발생한다(String password) { + //given + + //when + Throwable thrown = catchThrowable(() -> PasswordValidator.validate(password, null)); + + //then + assertThat(thrown).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 비밀번호에_생년월일_포함시_IllegalArgumentException이_발생한다() { + //given + String birthDate = "1990-04-27"; + String password = "pass1990-04-27"; + + //when + Throwable thrown = catchThrowable(() -> PasswordValidator.validate(password, birthDate)); + + //then + assertThat(thrown).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java b/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java index 7fad5872..ef0dc2e4 100644 --- a/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java +++ b/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java @@ -8,6 +8,6 @@ @Configuration @EnableTransactionManagement @EntityScan({"com.loopers"}) -@EnableJpaRepositories({"com.loopers.infrastructure"}) +@EnableJpaRepositories({"com.loopers.infrastructure", "com.loopers.user.repository"}) public class JpaConfig { }