diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md index a8ace53e..60520114 100644 --- a/.codeguide/loopers-1-week.md +++ b/.codeguide/loopers-1-week.md @@ -43,3 +43,90 @@ - [ ] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다. - [ ] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다. + +--- + +## 📋 구현 기록 + +### 1. 회원가입 기능 (`feature/sign-up`) + +**구현 파일:** +| 파일 | 역할 | +|------|------| +| `MemberModel.java` | 회원 엔티티 | +| `MemberRepository.java` | Repository 인터페이스 | +| `MemberService.java` | 비즈니스 로직 (중복 검증, 비밀번호 검증, 암호화) | +| `MemberJpaRepository.java` | Spring Data JPA 인터페이스 | +| `MemberRepositoryImpl.java` | Repository 구현체 | +| `MemberV1Controller.java` | REST API 컨트롤러 | +| `MemberV1Dto.java` | 요청/응답 DTO | +| `PasswordEncoderConfig.java` | BCrypt Bean 설정 | + +**설계 근거:** +- `spring-security-crypto`만 사용: 전체 Spring Security는 과한 의존성 +- Layered Architecture: Domain → Infrastructure → Interface 분리 +- 비밀번호 검증을 Service에 위치: PasswordEncoder 의존성 필요 + +**TDD 테스트 목록:** +| 테스트 | 검증 내용 | +|--------|----------| +| `register_withValidInfo_savesMember` | 정상 회원가입 | +| `register_withDuplicateLoginId_throwsException` | 로그인 ID 중복 검증 | +| `register_withShortPassword_throwsException` | 비밀번호 8자 미만 검증 | +| `register_withBirthDateInPassword_throwsException` | 생년월일 포함 검증 | +| `signUp_withValidRequest_returnsCreated` | API 201 응답 | +| `signUp_withInvalidLoginIdFormat_returnsBadRequest` | API 400 응답 | + +--- + +### 2. 내 정보 조회 기능 (`feature/my-info`) + +**구현 파일:** +| 파일 | 역할 | +|------|------| +| `AuthMember.java` | 인증 어노테이션 | +| `AuthMemberResolver.java` | 헤더 기반 인증 처리 | +| `WebMvcConfig.java` | Resolver 등록 | +| `MemberV1Dto.MyInfoResponse` | 응답 DTO (마스킹 로직 포함) | +| `MemberV1Controller.getMyInfo()` | API 추가 | +| `ErrorType.UNAUTHORIZED` | 401 에러 타입 | + +**설계 근거:** +- `HandlerMethodArgumentResolver` 사용: 컨트롤러 코드 깔끔, 인증 로직 집중 +- Facade 생략: 단순 조회이므로 Controller에서 직접 DTO 변환 +- 마스킹 로직을 DTO에 위치: 표현 계층 관심사 + +**TDD 테스트 목록:** +| 테스트 | 검증 내용 | +|--------|----------| +| `myInfoResponse_masksLastCharacterOfName` | 이름 마스킹 (홍길동 → 홍길*) | +| `myInfoResponse_doesNotMaskSingleCharacterName` | 1글자 이름 마스킹 안함 | +| `getMyInfo_withoutAuthHeaders_returnsUnauthorized` | 인증 헤더 없음 401 | +| `getMyInfo_withWrongPassword_returnsUnauthorized` | 잘못된 비밀번호 401 | +| `getMyInfo_withValidAuth_returnsOkWithMaskedName` | 정상 조회 200 | + +--- + +### 3. 비밀번호 수정 기능 (`feature/change-password`) + +**구현 파일:** +| 파일 | 역할 | +|------|------| +| `MemberModel.changePassword()` | 비밀번호 변경 메서드 | +| `MemberService.changePassword()` | 검증 로직 + 암호화 | +| `MemberV1Controller.changePassword()` | PATCH API | +| `MemberV1Dto.ChangePasswordRequest` | 요청 DTO | + +**설계 근거:** +- 기존 비밀번호 검증 로직 재사용 (`PASSWORD_PATTERN`, `containsBirthDate`) +- Facade 생략: 단순 흐름 (Controller → Service → Entity) + +**TDD 테스트 목록:** +| 테스트 | 검증 내용 | 상태 | +|--------|----------|------| +| `changePassword_withWrongCurrentPassword_throwsException` | 현재 비밀번호 불일치 | ✅ | +| `changePassword_withSamePassword_throwsException` | 동일 비밀번호 | ✅ | +| `changePassword_withInvalidNewPassword_throwsException` | 규칙 위반 | ✅ | +| `changePassword_withBirthDateInNewPassword_throwsException` | 생년월일 포함 | ✅ | +| `changePassword_withValidInput_updatesPassword` | 정상 변경 | ✅ | +| `changePassword_withValidAuth_returnsOk` | PATCH API 200 응답 | ✅ | diff --git a/.gitignore b/.gitignore index 5a979af6..cbf7c4d1 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +### Claude Code ### +CLAUDE.md diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..6d6b8bf4 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -8,6 +8,10 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + + // security (password encryption only) + implementation("org.springframework.security:spring-security-crypto") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad6..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e566..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 00000000..1cad2769 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,57 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "member") +public class Member extends BaseEntity { + + @Embedded + private LoginId loginId; + + @Embedded + private Password password; + + @Column(nullable = false, length = 50) + private String name; + + @Embedded + private BirthDate birthDate; + + @Embedded + private Email email; + + protected Member() {} + + public Member(LoginId loginId, Password password, String name, + BirthDate birthDate, Email email) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + } + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public LoginId getLoginId() { return loginId; } + public Password getPassword() { return password; } + public String getName() { return name; } + public BirthDate getBirthDate() { return birthDate; } + public Email getEmail() { return email; } + + public void changePassword(Password newPassword) { + this.password = newPassword; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 00000000..923b7f0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.vo.LoginId; + +import java.util.Optional; + +public interface MemberRepository { + Member save(Member member); + Optional findByLoginId(LoginId loginId); + boolean existsByLoginId(LoginId loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java new file mode 100644 index 00000000..43e8a899 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,60 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public Member register(String loginId, String plainPassword, String name, + String birthDate, String email) { + LoginId loginIdVo = new LoginId(loginId); + + if (memberRepository.existsByLoginId(loginIdVo)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 ID입니다."); + } + + BirthDate birthDateVo = BirthDate.from(birthDate); + Password password = Password.create(plainPassword, birthDateVo.value(), passwordEncoder); + Email emailVo = new Email(email); + + Member member = new Member(loginIdVo, password, name, birthDateVo, emailVo); + return memberRepository.save(member); + } + + public Optional findByLoginId(String loginId) { + return memberRepository.findByLoginId(new LoginId(loginId)); + } + + @Transactional + public void changePassword(Member member, String currentPlain, String newPlain) { + if (!member.getPassword().matches(currentPlain, passwordEncoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); + } + + if (member.getPassword().matches(newPlain, passwordEncoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, + "새 비밀번호는 현재 비밀번호와 달라야 합니다."); + } + + Password newPassword = Password.create( + newPlain, member.getBirthDate().value(), passwordEncoder); + member.changePassword(newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java new file mode 100644 index 00000000..6f4db2d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java @@ -0,0 +1,45 @@ +package com.loopers.domain.member.policy; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.regex.Pattern; + +public class PasswordPolicy { + + private static final Pattern FORMAT_PATTERN = + Pattern.compile("^[A-Za-z0-9!@#$%^&*()_+=-]{8,16}$"); + + public static void validate(String plain, LocalDate birthDate) { + validateFormat(plain); + validateNotContainsSubstrings(plain, + extractBirthDateStrings(birthDate), + "비밀번호에 생년월일을 포함할 수 없습니다."); + } + + public static void validateFormat(String plain) { + if (plain == null || !FORMAT_PATTERN.matcher(plain).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "비밀번호는 8~16자의 영문, 숫자, 특수문자만 허용됩니다."); + } + } + + public static void validateNotContainsSubstrings( + String plain, List forbidden, String errorMessage) { + for (String s : forbidden) { + if (plain.contains(s)) { + throw new CoreException(ErrorType.BAD_REQUEST, errorMessage); + } + } + } + + public static List extractBirthDateStrings(LocalDate birthDate) { + return List.of( + birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")), + birthDate.format(DateTimeFormatter.ofPattern("yyMMdd")) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java new file mode 100644 index 00000000..cd972596 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java @@ -0,0 +1,56 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Objects; + +@Embeddable +public class BirthDate { + + private static final DateTimeFormatter FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Column(name = "birth_date", nullable = false) + private LocalDate value; + + protected BirthDate() {} + + public BirthDate(LocalDate value) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 필수입니다."); + } + this.value = value; + } + + public static BirthDate from(String dateString) { + if (dateString == null || dateString.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 필수입니다."); + } + try { + return new BirthDate(LocalDate.parse(dateString, FORMATTER)); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 yyyy-MM-dd 형식이어야 합니다."); + } + } + + public LocalDate value() { return value; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof BirthDate birthDate)) return false; + return Objects.equals(value, birthDate.value); + } + + @Override + public int hashCode() { return Objects.hash(value); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java new file mode 100644 index 00000000..7562e18a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java @@ -0,0 +1,41 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +public class Email { + + private static final Pattern PATTERN = + Pattern.compile("^[\\w-.]+@[\\w-]+(\\.[a-z]{2,})+$"); + + @Column(name = "email", nullable = false, length = 100) + private String value; + + protected Email() {} + + public Email(String value) { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "올바른 이메일 형식이 아닙니다."); + } + this.value = value; + } + + public String value() { return value; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Email email)) return false; + return Objects.equals(value, email.value); + } + + @Override + public int hashCode() { return Objects.hash(value); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java new file mode 100644 index 00000000..d003c520 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java @@ -0,0 +1,40 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +public class LoginId { + + private static final Pattern PATTERN = Pattern.compile("^[A-Za-z0-9]{1,10}$"); + + @Column(name = "login_id", nullable = false, unique = true, length = 20) + private String value; + + protected LoginId() {} + + public LoginId(String value) { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "ID는 영문 및 숫자 10자 이내여야 합니다."); + } + this.value = value; + } + + public String value() { return value; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LoginId loginId)) return false; + return Objects.equals(value, loginId.value); + } + + @Override + public int hashCode() { return Objects.hash(value); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java new file mode 100644 index 00000000..d44acd58 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java @@ -0,0 +1,49 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.policy.PasswordPolicy; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; +import java.util.Objects; + +@Embeddable +public class Password { + + @Column(name = "password", nullable = false) + private String encoded; + + protected Password() {} + + public Password(String encoded) { + if (encoded == null || encoded.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); + } + this.encoded = encoded; + } + + public static Password create(String plain, LocalDate birthDate, + PasswordEncoder encoder) { + PasswordPolicy.validate(plain, birthDate); + return new Password(encoder.encode(plain)); + } + + public boolean matches(String plain, PasswordEncoder encoder) { + return encoder.matches(plain, this.encoded); + } + + public String encoded() { return encoded; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Password password)) return false; + return Objects.equals(encoded, password.encoded); + } + + @Override + public int hashCode() { return Objects.hash(encoded); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 00000000..edaadac0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + Optional findByLoginIdValue(String loginId); + boolean existsByLoginIdValue(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 00000000..a8be0aea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.LoginId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByLoginId(LoginId loginId) { + return memberJpaRepository.findByLoginIdValue(loginId.value()); + } + + @Override + public boolean existsByLoginId(LoginId loginId) { + return memberJpaRepository.existsByLoginIdValue(loginId.value()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c..f24379f0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +47,14 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(error -> String.format("'%s' %s", error.getField(), error.getDefaultMessage())) + .collect(Collectors.joining(", ")); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -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 = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 91737601..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 00000000..4be5598d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,59 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthMember; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller { + + private final MemberService memberService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse signUp(@Valid @RequestBody MemberV1Dto.SignUpRequest request) { + Member member = memberService.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + + MemberV1Dto.SignUpResponse response = new MemberV1Dto.SignUpResponse( + member.getId(), + member.getLoginId().value(), + member.getName(), + member.getEmail().value() + ); + + return ApiResponse.success(response); + } + + @GetMapping("/me") + public ApiResponse getMyInfo(@AuthMember Member member) { + return ApiResponse.success(MemberV1Dto.MyInfoResponse.from(member)); + } + + @PatchMapping("/me/password") + public ApiResponse changePassword( + @AuthMember Member member, + @Valid @RequestBody MemberV1Dto.ChangePasswordRequest request + ) { + memberService.changePassword(member, request.currentPassword(), request.newPassword()); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 00000000..13113ae1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import jakarta.validation.constraints.NotBlank; + +import java.time.LocalDate; + +public class MemberV1Dto { + + public record SignUpRequest( + @NotBlank String loginId, + @NotBlank String password, + @NotBlank String name, + @NotBlank String birthDate, + @NotBlank String email + ) {} + + public record SignUpResponse( + Long id, + String loginId, + String name, + String email + ) {} + + public record MyInfoResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static MyInfoResponse from(Member member) { + return new MyInfoResponse( + member.getLoginId().value(), + maskName(member.getName()), + member.getBirthDate().value(), + member.getEmail().value() + ); + } + + private static String maskName(String name) { + if (name == null || name.length() < 2) { + return name; + } + return name.substring(0, name.length() - 1) + "*"; + } + } + + public record ChangePasswordRequest( + @NotBlank String currentPassword, + @NotBlank String newPassword + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java new file mode 100644 index 00000000..9089d89d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java @@ -0,0 +1,11 @@ +package com.loopers.support.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthMember { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java new file mode 100644 index 00000000..978e677f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java @@ -0,0 +1,60 @@ +package com.loopers.support.auth; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +@Component +public class AuthMemberResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + + String loginId = request.getHeader(HEADER_LOGIN_ID); + String password = request.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank() || password == null || password.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 필요합니다."); + } + + Member member = memberRepository.findByLoginId(new LoginId(loginId)) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, + "아이디 또는 비밀번호가 일치하지 않습니다.")); + + if (!member.getPassword().matches(password, passwordEncoder)) { + throw new CoreException(ErrorType.UNAUTHORIZED, + "아이디 또는 비밀번호가 일치하지 않습니다."); + } + + return member; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/PasswordEncoderConfig.java new file mode 100644 index 00000000..b8cf0547 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.support.auth; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java new file mode 100644 index 00000000..edb1b604 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.loopers.support.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthMemberResolver authMemberResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authMemberResolver); + } +} 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..bc9fb4c7 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 @@ -10,6 +10,7 @@ public enum ErrorType { /** 범용 에러 */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증이 필요합니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -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 static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java new file mode 100644 index 00000000..5f592560 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -0,0 +1,96 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +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.test.context.bean.override.mockito.MockitoSpyBean; + +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.Mockito.verify; + +@SpringBootTest +class MemberServiceIntegrationTest { + + @MockitoSpyBean + private MemberRepository memberRepository; + + @Autowired + private MemberService memberService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원 가입") + @Nested + class Register { + + @DisplayName("회원 가입시 User 저장이 수행된다") + @Test + void register_savesUser_verifiedBySpy() { + // act + Member result = memberService.register( + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com"); + + // assert + verify(memberRepository).save(any(Member.class)); + assertThat(result.getId()).isNotNull(); + } + + @DisplayName("이미 가입된 ID로 회원가입 시도 시 실패한다") + @Test + void register_withDuplicateId_throwsException() { + // arrange + memberService.register( + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com"); + + // act & assert + assertThatThrownBy(() -> memberService.register( + "user1", "Password2!", "김철수", "1995-05-20", "other@example.com")) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("내 정보 조회") + @Nested + class FindByLoginId { + + @DisplayName("해당 ID의 회원이 존재할 경우 회원 정보가 반환된다") + @Test + void findByLoginId_whenExists_returnsMember() { + // arrange + memberService.register( + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com"); + + // act + Optional result = memberService.findByLoginId("user1"); + + // assert + assertThat(result).isPresent(); + assertThat(result.get().getLoginId().value()).isEqualTo("user1"); + } + + @DisplayName("해당 ID의 회원이 존재하지 않을 경우 null이 반환된다") + @Test + void findByLoginId_whenNotExists_returnsEmpty() { + // act + Optional result = memberService.findByLoginId("nobody"); + + // assert + assertThat(result).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java new file mode 100644 index 00000000..c3e94a14 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,69 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MemberTest { + + @DisplayName("유효한 정보로 Member를 생성할 수 있다") + @Test + void create_withValidInfo_succeeds() { + Member member = new Member( + new LoginId("user1"), + new Password("encodedPw"), + "홍길동", + BirthDate.from("1990-01-15"), + new Email("test@example.com") + ); + + assertThat(member.getLoginId().value()).isEqualTo("user1"); + assertThat(member.getName()).isEqualTo("홍길동"); + } + + @DisplayName("이름이 null이면 생성에 실패한다") + @Test + void create_withNullName_throwsException() { + assertThatThrownBy(() -> new Member( + new LoginId("user1"), + new Password("encodedPw"), + null, + BirthDate.from("1990-01-15"), + new Email("test@example.com") + )).isInstanceOf(CoreException.class); + } + + @DisplayName("이름이 빈 문자열이면 생성에 실패한다") + @Test + void create_withBlankName_throwsException() { + assertThatThrownBy(() -> new Member( + new LoginId("user1"), + new Password("encodedPw"), + " ", + BirthDate.from("1990-01-15"), + new Email("test@example.com") + )).isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호를 변경할 수 있다") + @Test + void changePassword_updatesPassword() { + Member member = new Member( + new LoginId("user1"), + new Password("oldEncodedPw"), + "홍길동", + BirthDate.from("1990-01-15"), + new Email("test@example.com") + ); + + member.changePassword(new Password("newEncodedPw")); + assertThat(member.getPassword().encoded()).isEqualTo("newEncodedPw"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java new file mode 100644 index 00000000..f94b787f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java @@ -0,0 +1,76 @@ +package com.loopers.domain.member.policy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PasswordPolicyTest { + + @DisplayName("유효한 비밀번호는 검증을 통과한다") + @Test + void validate_withValidPassword_succeeds() { + assertThatNoException().isThrownBy(() -> + PasswordPolicy.validate("Password1!", LocalDate.of(1990, 1, 15))); + } + + @DisplayName("8자 미만 비밀번호는 실패한다") + @Test + void validateFormat_withShortPassword_throwsException() { + assertThatThrownBy(() -> PasswordPolicy.validateFormat("Pass1!")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("16자 초과 비밀번호는 실패한다") + @Test + void validateFormat_withLongPassword_throwsException() { + assertThatThrownBy(() -> PasswordPolicy.validateFormat("A".repeat(17))) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null 비밀번호는 실패한다") + @Test + void validateFormat_withNull_throwsException() { + assertThatThrownBy(() -> PasswordPolicy.validateFormat(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("생년월일(yyyyMMdd) 포함 비밀번호는 실패한다") + @Test + void validate_withBirthDateYYYYMMDD_throwsException() { + assertThatThrownBy(() -> + PasswordPolicy.validate("Pass19900115!", LocalDate.of(1990, 1, 15))) + .isInstanceOf(CoreException.class); + } + + @DisplayName("생년월일(yyMMdd) 포함 비밀번호는 실패한다") + @Test + void validate_withBirthDateYYMMDD_throwsException() { + assertThatThrownBy(() -> + PasswordPolicy.validate("Pass900115!!", LocalDate.of(1990, 1, 15))) + .isInstanceOf(CoreException.class); + } + + @DisplayName("금지 문자열이 포함되면 실패한다") + @Test + void validateNotContainsSubstrings_withForbidden_throwsException() { + assertThatThrownBy(() -> + PasswordPolicy.validateNotContainsSubstrings( + "hello_forbidden_world", + List.of("forbidden"), + "금지 문자열 포함")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("extractBirthDateStrings는 yyyyMMdd와 yyMMdd를 반환한다") + @Test + void extractBirthDateStrings_returnsTwoFormats() { + List result = PasswordPolicy.extractBirthDateStrings(LocalDate.of(1990, 1, 15)); + org.assertj.core.api.Assertions.assertThat(result).containsExactly("19900115", "900115"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java new file mode 100644 index 00000000..42428864 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java @@ -0,0 +1,48 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BirthDateTest { + + @DisplayName("yyyy-MM-dd 형식의 문자열로 BirthDate를 생성할 수 있다") + @Test + void from_withValidFormat_succeeds() { + BirthDate birthDate = BirthDate.from("1990-01-15"); + assertThat(birthDate.value()).isEqualTo(LocalDate.of(1990, 1, 15)); + } + + @DisplayName("생년월일이 yyyy-MM-dd 형식에 맞지 않으면 User 객체 생성에 실패한다") + @Test + void from_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> BirthDate.from("19900115")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null이면 생성에 실패한다") + @Test + void from_withNull_throwsException() { + assertThatThrownBy(() -> BirthDate.from(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("슬래시 형식이면 생성에 실패한다") + @Test + void from_withSlashFormat_throwsException() { + assertThatThrownBy(() -> BirthDate.from("1990/01/15")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("빈 문자열이면 생성에 실패한다") + @Test + void from_withEmpty_throwsException() { + assertThatThrownBy(() -> BirthDate.from("")) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java new file mode 100644 index 00000000..5976980f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java @@ -0,0 +1,46 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EmailTest { + + @DisplayName("유효한 이메일 형식으로 Email을 생성할 수 있다") + @Test + void create_withValidFormat_succeeds() { + Email email = new Email("test@example.com"); + assertThat(email.value()).isEqualTo("test@example.com"); + } + + @DisplayName("이메일이 xx@yy.zz 형식에 맞지 않으면 User 객체 생성에 실패한다") + @Test + void create_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> new Email("invalid-email")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("@가 없으면 생성에 실패한다") + @Test + void create_withoutAtSign_throwsException() { + assertThatThrownBy(() -> new Email("testexample.com")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null이면 생성에 실패한다") + @Test + void create_withNull_throwsException() { + assertThatThrownBy(() -> new Email(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("도메인 부분이 없으면 생성에 실패한다") + @Test + void create_withoutDomain_throwsException() { + assertThatThrownBy(() -> new Email("test@")) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java new file mode 100644 index 00000000..382e5bb1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java @@ -0,0 +1,53 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LoginIdTest { + + @DisplayName("유효한 영문 및 숫자 조합으로 LoginId를 생성할 수 있다") + @Test + void create_withValidFormat_succeeds() { + LoginId loginId = new LoginId("user1234"); + assertThat(loginId.value()).isEqualTo("user1234"); + } + + @DisplayName("ID가 영문 및 숫자 10자 이내 형식에 맞지 않으면 User 객체 생성에 실패한다") + @Test + void create_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> new LoginId("한글아이디")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("10자를 초과하면 생성에 실패한다") + @Test + void create_withTooLong_throwsException() { + assertThatThrownBy(() -> new LoginId("abcdefghijk")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("특수문자가 포함되면 생성에 실패한다") + @Test + void create_withSpecialChars_throwsException() { + assertThatThrownBy(() -> new LoginId("user!@#")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null이면 생성에 실패한다") + @Test + void create_withNull_throwsException() { + assertThatThrownBy(() -> new LoginId(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("빈 문자열이면 생성에 실패한다") + @Test + void create_withEmpty_throwsException() { + assertThatThrownBy(() -> new LoginId("")) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java new file mode 100644 index 00000000..e47be0ad --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java @@ -0,0 +1,59 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PasswordTest { + + private final PasswordEncoder encoder = new BCryptPasswordEncoder(); + + @DisplayName("유효한 비밀번호로 Password를 생성할 수 있다") + @Test + void create_withValidPassword_succeeds() { + Password password = Password.create("Password1!", LocalDate.of(1990, 1, 15), encoder); + assertThat(password.encoded()).isNotBlank(); + } + + @DisplayName("비밀번호가 형식에 맞지 않으면 생성에 실패한다") + @Test + void create_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> Password.create("short", LocalDate.of(1990, 1, 15), encoder)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호에 생년월일이 포함되면 생성에 실패한다") + @Test + void create_withBirthDate_throwsException() { + assertThatThrownBy(() -> Password.create("Pass19900115!", LocalDate.of(1990, 1, 15), encoder)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("matches로 평문 비밀번호를 검증할 수 있다") + @Test + void matches_withCorrectPassword_returnsTrue() { + Password password = Password.create("Password1!", LocalDate.of(1990, 1, 15), encoder); + assertThat(password.matches("Password1!", encoder)).isTrue(); + } + + @DisplayName("matches로 틀린 비밀번호를 거부할 수 있다") + @Test + void matches_withWrongPassword_returnsFalse() { + Password password = Password.create("Password1!", LocalDate.of(1990, 1, 15), encoder); + assertThat(password.matches("WrongPass1!", encoder)).isFalse(); + } + + @DisplayName("encoded가 null이면 생성에 실패한다") + @Test + void constructor_withNull_throwsException() { + assertThatThrownBy(() -> new Password(null)) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba6..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java new file mode 100644 index 00000000..b861baea --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java @@ -0,0 +1,133 @@ +package com.loopers.interfaces.api.member; + +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.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MemberV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private ResponseEntity> signUp(Map body) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> request = new HttpEntity<>(body, headers); + return testRestTemplate.exchange( + "/api/v1/members", + HttpMethod.POST, + request, + new ParameterizedTypeReference<>() {} + ); + } + + private Map validSignUpBody() { + return Map.of( + "loginId", "user1", + "password", "Password1!", + "name", "홍길동", + "birthDate", "1990-01-15", + "email", "test@example.com" + ); + } + + @DisplayName("POST /api/v1/members (회원 가입)") + @Nested + class SignUp { + + @DisplayName("회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다") + @Test + void signUp_withValidRequest_returnsCreatedWithUserInfo() { + // act + ResponseEntity> response = signUp(validSignUpBody()); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("user1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + } + + @DisplayName("GET /api/v1/members/me (내 정보 조회)") + @Nested + class GetMyInfo { + + @DisplayName("내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다") + @Test + void getMyInfo_withValidAuth_returnsUserInfo() { + // arrange + signUp(validSignUpBody()); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "user1"); + headers.set("X-Loopers-LoginPw", "Password1!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("user1"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 ID로 조회할 경우, 401 Unauthorized") + @Test + void getMyInfo_withNonExistentId_returnsUnauthorized() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nobody"); + headers.set("X-Loopers-LoginPw", "Password1!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } +} diff --git a/http/commerce-api/example-v1.http b/http/commerce-api/example-v1.http deleted file mode 100644 index 2a924d26..00000000 --- a/http/commerce-api/example-v1.http +++ /dev/null @@ -1,2 +0,0 @@ -### 예시 조회 -GET {{commerce-api}}/api/v1/examples/1 \ No newline at end of file diff --git a/plans/week1.md b/plans/week1.md new file mode 100644 index 00000000..49cf0c8c --- /dev/null +++ b/plans/week1.md @@ -0,0 +1,448 @@ +# Week 1 - 회원 기능 + +## 요구사항 + +### 1. 회원가입 + +| 항목 | 내용 | +|------|------| +| 필요 정보 | 로그인 ID, 비밀번호, 이름, 생년월일, 이메일 | +| 로그인 ID | 영문 + 숫자만 허용, 중복 불가 | +| 포맷 검증 | 이름, 이메일, 생년월일 | +| 비밀번호 규칙 | 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용 | +| 비밀번호 제약 | 생년월일 포함 불가 | +| 저장 방식 | 비밀번호 암호화 저장 | + +### 2. 내 정보 조회 + +| 항목 | 내용 | +|------|------| +| 인증 방식 | HTTP 헤더 (`X-Loopers-LoginId`, `X-Loopers-LoginPw`) | +| 반환 정보 | 로그인 ID, 이름, 생년월일, 이메일 | +| 이름 마스킹 | 마지막 글자를 `*`로 마스킹 (예: 홍길동 → 홍길*) | + +### 3. 비밀번호 수정 + +| 항목 | 내용 | +|------|------| +| 인증 방식 | HTTP 헤더 (`X-Loopers-LoginId`, `X-Loopers-LoginPw`) | +| 필요 정보 | 기존 비밀번호, 새 비밀번호 | +| 비밀번호 규칙 | 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용 | +| 비밀번호 제약 | 생년월일 포함 불가, 현재 비밀번호 사용 불가 | + +--- + +## 기술 결정 사항 + +| 항목 | 결정 | 근거 | +|------|------|------| +| 비밀번호 암호화 | `spring-security-crypto` | 전체 Spring Security는 과한 의존성, crypto만 사용 | +| 인증 처리 | `HandlerMethodArgumentResolver` | 대중적, 확장성 좋음, 컨트롤러 코드 깔끔 | +| 엔티티 네이밍 | `MemberModel` | 기존 프로젝트 패턴(`ExampleModel`) 유지 | +| DTO | Record 사용 | Java 21 기본 기능, boilerplate 감소 | +| 검증 | `@Valid` 기본 어노테이션 + 서비스 레벨 검증 | 오버엔지니어링 방지 | + +--- + +## 구현 계획 (소스 레벨) + +### Phase 1: 공통 기반 구축 + +#### 1-1. 의존성 추가 (`apps/commerce-api/build.gradle.kts`) + +```kotlin +// 비밀번호 암호화 (spring-security-crypto만 사용) +implementation("org.springframework.security:spring-security-crypto") +``` + +#### 1-2. MemberModel 엔티티 + +**파일**: `domain/member/MemberModel.java` + +```java +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + @Column(nullable = false, unique = true, length = 20) + private String loginId; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false) + private LocalDate birthDate; + + @Column(nullable = false, length = 100) + private String email; + + // 생성자, getter, 비밀번호 변경 메서드 +} +``` + +**설계 근거**: +- `loginId`: unique 제약, 영문+숫자만 허용 +- `password`: BCrypt 해시값 저장 (길이 제한 없음) +- `birthDate`: `LocalDate` 타입으로 날짜만 저장 +- `BaseEntity` 상속으로 id, createdAt, updatedAt, deletedAt 자동 관리 + +#### 1-3. MemberRepository + +**파일**: `domain/member/MemberRepository.java` + +```java +public interface MemberRepository { + MemberModel save(MemberModel member); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} +``` + +#### 1-4. MemberRepositoryImpl + +**파일**: `infrastructure/member/MemberRepositoryImpl.java` + +```java +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + private final MemberJpaRepository memberJpaRepository; + + // 구현 +} +``` + +#### 1-5. MemberJpaRepository + +**파일**: `infrastructure/member/MemberJpaRepository.java` + +```java +public interface MemberJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} +``` + +#### 1-6. PasswordEncoder 설정 + +**파일**: `support/auth/PasswordEncoderConfig.java` + +```java +@Configuration +public class PasswordEncoderConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} +``` + +**설계 근거**: +- `spring-security-crypto`의 `PasswordEncoder` 인터페이스 사용 +- BCrypt 알고리즘 (업계 표준) + +#### 1-7. PasswordValidator + +**파일**: `domain/member/PasswordValidator.java` + +```java +@Component +public class PasswordValidator { + + // 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용 + private static final String PASSWORD_PATTERN = "^[A-Za-z0-9!@#$%^&*()_+=-]{8,16}$"; + + public void validate(String password, LocalDate birthDate) { + // 1. 길이 및 문자 규칙 검증 + // 2. 생년월일 포함 여부 검증 (yyyyMMdd, yyMMdd 등) + } +} +``` + +**검증 항목**: +- 길이: 8~16자 +- 허용 문자: 영문 대소문자, 숫자, 특수문자 +- 생년월일 포함 불가: `19900101`, `900101` 등 패턴 체크 + +#### 1-8. 인증 컴포넌트 (HandlerMethodArgumentResolver) + +**파일**: `support/auth/AuthMember.java` (어노테이션) + +```java +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthMember { +} +``` + +**파일**: `support/auth/AuthMemberResolver.java` + +```java +@RequiredArgsConstructor +@Component +public class AuthMemberResolver implements HandlerMethodArgumentResolver { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class); + } + + @Override + public Object resolveArgument(...) { + String loginId = request.getHeader("X-Loopers-LoginId"); + String password = request.getHeader("X-Loopers-LoginPw"); + + // 1. 헤더 존재 여부 검증 + // 2. 회원 조회 + // 3. 비밀번호 일치 검증 + // 4. MemberModel 반환 + } +} +``` + +**파일**: `support/auth/WebMvcConfig.java` + +```java +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthMemberResolver authMemberResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authMemberResolver); + } +} +``` + +--- + +### Phase 2: 회원가입 기능 + +#### 2-1. API 설계 + +| 항목 | 내용 | +|------|------| +| Method | `POST` | +| Path | `/api/v1/members` | +| Request | `{ loginId, password, name, birthDate, email }` | +| Response | `201 Created` + `{ id, loginId, name, email }` | + +#### 2-2. DTO + +**파일**: `interfaces/api/member/MemberV1Dto.java` + +```java +public class MemberV1Dto { + + public record SignUpRequest( + @NotBlank @Pattern(regexp = "^[A-Za-z0-9]+$") String loginId, + @NotBlank @Size(min = 8, max = 16) String password, + @NotBlank String name, + @NotNull @Past LocalDate birthDate, + @NotBlank @Email String email + ) {} + + public record SignUpResponse( + Long id, + String loginId, + String name, + String email + ) { + public static SignUpResponse from(MemberInfo info) { ... } + } +} +``` + +#### 2-3. 계층별 구현 + +**Controller** → **Facade** → **Service** → **Repository** + +``` +MemberV1Controller.signUp(SignUpRequest) + ↓ +MemberFacade.signUp(SignUpCommand) + ↓ +MemberService.register(SignUpCommand) + - 로그인 ID 중복 검증 + - 비밀번호 검증 (PasswordValidator) + - 비밀번호 암호화 + - 저장 + ↓ +MemberRepository.save(MemberModel) +``` + +#### 2-4. TDD 사이클 + +| 사이클 | 테스트 케이스 | 구현 내용 | +|--------|-------------|----------| +| 1 | 정상 가입 시 201 응답 | 기본 API 흐름 구현 | +| 2 | 로그인 ID 중복 시 409 응답 | 중복 검증 로직 추가 | +| 3 | 로그인 ID 포맷 오류 시 400 응답 | @Pattern 검증 | +| 4 | 비밀번호 규칙 위반 시 400 응답 | PasswordValidator 연동 | +| 5 | 비밀번호에 생년월일 포함 시 400 응답 | 생년월일 검증 로직 | +| 6 | 이메일 포맷 오류 시 400 응답 | @Email 검증 | + +--- + +### Phase 3: 내 정보 조회 기능 + +#### 3-1. API 설계 + +| 항목 | 내용 | +|------|------| +| Method | `GET` | +| Path | `/api/v1/members/me` | +| Headers | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| Response | `200 OK` + `{ loginId, name, birthDate, email }` | + +#### 3-2. DTO + +```java +public record MyInfoResponse( + String loginId, + String name, // 마스킹 적용 + LocalDate birthDate, + String email +) { + public static MyInfoResponse from(MemberModel member) { + return new MyInfoResponse( + member.getLoginId(), + maskName(member.getName()), + member.getBirthDate(), + member.getEmail() + ); + } + + private static String maskName(String name) { + if (name == null || name.length() < 2) return name; + return name.substring(0, name.length() - 1) + "*"; + } +} +``` + +#### 3-3. 계층별 구현 + +``` +MemberV1Controller.getMyInfo(@AuthMember MemberModel member) + ↓ +MyInfoResponse.from(member) // 직접 변환 (Facade 생략 가능) +``` + +**설계 근거**: 단순 조회이므로 Facade 없이 Controller에서 직접 DTO 변환 + +#### 3-4. TDD 사이클 + +| 사이클 | 테스트 케이스 | 구현 내용 | +|--------|-------------|----------| +| 1 | 정상 조회 시 200 응답 | 기본 API 흐름 | +| 2 | 이름 마스킹 검증 | maskName 로직 | +| 3 | 인증 헤더 없음 시 401 응답 | AuthMemberResolver 예외 처리 | +| 4 | 잘못된 비밀번호 시 401 응답 | 비밀번호 검증 | + +--- + +### Phase 4: 비밀번호 수정 기능 + +#### 4-1. API 설계 + +| 항목 | 내용 | +|------|------| +| Method | `PATCH` | +| Path | `/api/v1/members/me/password` | +| Headers | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| Request | `{ currentPassword, newPassword }` | +| Response | `200 OK` | + +#### 4-2. DTO + +```java +public record ChangePasswordRequest( + @NotBlank String currentPassword, + @NotBlank @Size(min = 8, max = 16) String newPassword +) {} +``` + +#### 4-3. 계층별 구현 + +``` +MemberV1Controller.changePassword(@AuthMember MemberModel member, ChangePasswordRequest) + ↓ +MemberFacade.changePassword(member, ChangePasswordCommand) + ↓ +MemberService.changePassword(member, currentPassword, newPassword) + - 현재 비밀번호 일치 검증 + - 새 비밀번호 규칙 검증 + - 새 비밀번호 ≠ 현재 비밀번호 검증 + - 비밀번호 암호화 후 업데이트 +``` + +#### 4-4. TDD 사이클 + +| 사이클 | 테스트 케이스 | 구현 내용 | +|--------|-------------|----------| +| 1 | 정상 수정 시 200 응답 | 기본 API 흐름 | +| 2 | 현재 비밀번호 불일치 시 400 응답 | 비밀번호 검증 | +| 3 | 새 비밀번호 규칙 위반 시 400 응답 | PasswordValidator | +| 4 | 새 비밀번호 = 현재 비밀번호 시 400 응답 | 동일 비밀번호 검증 | +| 5 | 새 비밀번호에 생년월일 포함 시 400 응답 | 생년월일 검증 | + +--- + +## 브랜치 전략 + +``` +main + └── week1 + ├── feature/sign-up (Phase 1 + Phase 2) + ├── feature/my-info (Phase 3) + └── feature/change-password (Phase 4) +``` + +--- + +## 패키지 구조 (최종) + +``` +com.loopers +├── application/member/ +│ ├── MemberFacade.java +│ └── MemberInfo.java +├── domain/member/ +│ ├── MemberModel.java +│ ├── MemberService.java +│ ├── MemberRepository.java +│ └── PasswordValidator.java +├── infrastructure/member/ +│ ├── MemberJpaRepository.java +│ └── MemberRepositoryImpl.java +├── interfaces/api/member/ +│ ├── MemberV1Controller.java +│ ├── MemberV1ApiSpec.java +│ └── MemberV1Dto.java +└── support/ + └── auth/ + ├── AuthMember.java + ├── AuthMemberResolver.java + ├── PasswordEncoderConfig.java + └── WebMvcConfig.java +``` + +--- + +## ErrorType 추가 (필요시) + +```java +// 기존 ErrorType에 추가 +UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized", "인증이 필요합니다."), +DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "Duplicate Login ID", "이미 존재하는 로그인 ID입니다."), +INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "Invalid Password", "비밀번호 규칙에 맞지 않습니다."), +PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "Password Mismatch", "비밀번호가 일치하지 않습니다."), +```