diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..fc2469a0 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 5a979af6..1a1ad415 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +### Study ### +docs/study/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..1ac9cbe4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,180 @@ +# CLAUDE.md + +## 프로젝트 개요 + +Spring Boot 기반 커머스 멀티모듈 템플릿 프로젝트 (`loopers-java-spring-template`). +REST API, 배치 처리, 이벤트 스트리밍을 위한 마이크로서비스 아키텍처 패턴을 제공한다. + +## 기술 스택 및 버전 + +| 구분 | 기술 | 버전 | +|------|------|------| +| Language | Java | 21 | +| Language | Kotlin | 2.0.20 | +| Framework | Spring Boot | 3.4.4 | +| Framework | Spring Cloud | 2024.0.1 | +| Dependency Mgmt | spring-dependency-management | 1.1.7 | +| Database | MySQL | 8.0 | +| ORM | Spring Data JPA + QueryDSL | (Spring 관리) | +| Cache | Redis (Master-Replica) | 7.0 | +| Messaging | Apache Kafka (KRaft) | 3.5.1 | +| API Docs | SpringDoc OpenAPI | 2.7.0 | +| Monitoring | Micrometer + Prometheus | (Spring 관리) | +| Tracing | Micrometer Brave | (Spring 관리) | +| Logging | Logback + Slack Appender | 1.6.1 | +| Testing | JUnit 5, Mockito 5.14.0, SpringMockK 4.0.2, Instancio 5.0.2 | +| Testing Infra | TestContainers (MySQL, Redis, Kafka) | (Spring 관리) | +| Code Coverage | JaCoCo | (Gradle 관리) | +| Build Tool | Gradle (Kotlin DSL) | Wrapper 포함 | + +## 모듈 구조 + +``` +root +├── apps/ # 실행 가능한 Spring Boot 애플리케이션 +│ ├── commerce-api # REST API 서버 (Tomcat) +│ ├── commerce-batch # Spring Batch 배치 처리 +│ └── commerce-streamer # Kafka Consumer 스트리밍 서비스 +├── modules/ # 재사용 가능한 인프라 모듈 (java-library) +│ ├── jpa # JPA + MySQL + QueryDSL + HikariCP +│ ├── redis # Redis Master-Replica (Lettuce) +│ └── kafka # Kafka Producer/Consumer 설정 +├── supports/ # 횡단 관심사 모듈 +│ ├── jackson # Jackson ObjectMapper 커스터마이징 +│ ├── logging # 구조화 로깅 + Slack 연동 +│ └── monitoring # Prometheus 메트릭 + Health Probe +└── docker/ + ├── infra-compose.yml # MySQL, Redis, Kafka, Kafka UI + └── monitoring-compose.yml # Prometheus, Grafana +``` + +## 아키텍처 레이어 (commerce-api 기준) + +``` +interfaces/api/ → REST Controller, DTO, ApiSpec +application/ → Facade (유즈케이스 오케스트레이션), Info DTO +domain/ → Entity, Repository 인터페이스, Service (비즈니스 로직) +infrastructure/ → Repository 구현체 +support/error/ → CoreException, ErrorType +``` + +## 빌드 및 실행 + +```bash +# 인프라 구동 +docker compose -f docker/infra-compose.yml up -d + +# 모니터링 스택 (Grafana: localhost:3000, admin/admin) +docker compose -f docker/monitoring-compose.yml up -d + +# 빌드 +./gradlew clean build + +# 실행 +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-batch:bootRun --args='--job.name=demoJob' +./gradlew :apps:commerce-streamer:bootRun + +# 테스트 +./gradlew test + +# 코드 커버리지 +./gradlew test jacocoTestReport +``` + +## 테스트 설정 + +- JUnit 5 기반, 테스트 프로파일: `test`, 타임존: `Asia/Seoul` +- TestContainers로 MySQL/Redis/Kafka 통합 테스트 +- 모듈별 `testFixtures`로 테스트 유틸리티 공유 (`DatabaseCleanUp`, `RedisCleanUp` 등) +- 테스트 병렬 실행 없음 (`maxParallelForks = 1`) + +## 프로파일 + +`local`, `test`, `dev`, `qa`, `prd` — 환경별 설정은 각 모듈의 yml 파일에서 프로파일 그룹으로 관리. +운영 환경은 환경변수로 주입: `MYSQL_HOST`, `REDIS_MASTER_HOST`, `BOOTSTRAP_SERVERS` 등. + +## 주요 패턴 + +- **BaseEntity**: ID 자동생성, `createdAt`/`updatedAt` 감사, `deletedAt` 소프트 삭제 +- **ApiResponse**: 통일된 응답 래퍼 (`meta.result`, `meta.errorCode`, `data`) +- **CoreException + ErrorType**: 타입 기반 에러 처리 (400, 404, 409, 500) +- **별도 관리 포트**: 메트릭/헬스체크는 8081 포트로 분리 +- **Kafka 배치 소비**: 3000건 배치, 수동 커밋 (Manual ACK) +- **Redis 읽기 분산**: Master 쓰기, Replica 읽기 분리 + +## API 응답 규칙 +- 모든 응답은 `ApiResponse`로 래핑 +- 성공: `ApiResponse.success(data)` 반환 +- 실패: `CoreException(ErrorType)` throw → GlobalExceptionHandler에서 처리 +- 생성 API: `@ResponseStatus(HttpStatus.CREATED)` + +## 의존성 방향 (외부 → 내부) +``` +interfaces → application → domain ← infrastructure +``` +- domain 계층은 다른 계층에 의존하지 않음 +- infrastructure는 domain의 Repository 인터페이스를 구현 + +## 문서 작성 +### 다이어그램 작성 +- ERD, 시퀀스 다이어그램, 클래스 다이어그램 등 작성 시 mermaid를 이용한 마크다운으로 작성. + +## 개발 규칙 +### 진행 Workflow - 증강 코딩 +- **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. +- **중간 결과 보고** : AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입. +- **설계 주도권 유지** : AI 가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행. +- 구현은 한 단계씩 순서대로 진행 및 단계가 끝날 때 마다 핵심 개념/키워드 설명. +- API는 RESTFul API로 구현 +### 인증 요청 +- 유저 정보가 필요한 모든 요청은 아래 헤더를 통해 요청 +* X-Loopers-LoginId : 로그인 ID +* X-Loopers-LoginPw : 비밀번호 + +### 개발 Workflow - TDD (Red > Green > Refactor) +- 모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert) +#### 1. Red Phase : 실패하는 테스트 먼저 작성 +- 요구사항을 만족하는 기능 테스트 케이스 작성 +- 테스트 예시 +#### 2. Green Phase : 테스트를 통과하는 코드 작성 +- Red Phase 의 테스트가 모두 통과할 수 있는 코드 작성 +- 오버엔지니어링 금지 +#### 3. Refactor Phase : 불필요한 코드 제거 및 품질 개선 +- 불필요한 private 함수 지양, 객체지향적 코드 작성 +- unused import 제거 +- 성능 최적화 +- 모든 테스트 케이스가 통과해야 함 + +## 주의사항 +### 1. Never Do +- 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이용한 구현을 하지 말 것 +- null-safety 하지 않게 코드 작성하지 말 것 (Java 의 경우, Optional 을 활용할 것) +- println 코드 남기지 말 것 +- 객체지향 5원칙을 어기지 말 것 + +### 2. Recommendation +- 실제 API 를 호출해 확인하는 E2E 테스트 코드 작성 +- 재사용 가능한 객체 설계 +- 성능 최적화에 대한 대안 및 제안 +- 개발 완료된 API 의 경우, `.http/**.http` 에 분류해 작성 +- Domain Entity와 Persistence Entity는 구분하여 구현 +- 필요한 의존성은 적절히 관리하여 최소화 +- 통합 테스트는 테스트 컨테이너를 이용해 진행 +- 테스트 코드 작성 시 MIN, MAX, EDGE 케이스를 고려하여 작성 + +### 3. Priority +1. 실제 동작하는 해결책만 고려 +2. null-safety, thread-safety 고려 +3. 테스트 가능한 구조로 설계 +4. 기존 코드 패턴 분석 후 일관성 유지 + +## 깃 커밋 컨벤션 +- feat: 새로운 기능 추가 +- fix: 버그 수정 +- docs: 문서만 수정 (예: README, 주석은 아님) +- style: 코드 포맷팅 (공백, 세미콜론 등 기능 변화 없음) +- refactor: 기능 변화 없이 코드 개선 +- test: 테스트 코드 추가/수정 +- chore: 빌드/패키지 설정 등 기능과 직접 관련 없는 작업 +- 커밋 메세지는 한국어로 작성할 것 \ No newline at end of file diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..dae7d09a 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -6,8 +6,12 @@ dependencies { implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + // security + implementation("org.springframework.security:spring-security-crypto") + // web implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") 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/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 00000000..c82a3e33 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,29 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberService memberService; + + public MemberInfo signUp(String loginId, String password, String name, LocalDate birthday, String email) { + Member member = memberService.signUp(loginId, password, name, birthday, email); + return MemberInfo.from(member); + } + + public MemberInfo getMyInfo(String loginId, String password) { + Member member = memberService.authenticate(loginId, password); + return MemberInfo.from(member).withMaskedName(); + } + + public void updatePassword(String loginId, String currentPassword, String newPassword) { + memberService.updatePassword(loginId, currentPassword, newPassword); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java new file mode 100644 index 00000000..d5b491d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,22 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; + +import java.time.LocalDate; + +public record MemberInfo(Long id, String loginId, String name, LocalDate birthday, String email) { + public static MemberInfo from(Member member) { + return new MemberInfo( + member.getId(), + member.getLoginId(), + member.getName(), + member.getBirthday(), + member.getEmail() + ); + } + + public MemberInfo withMaskedName() { + String maskedName = name.substring(0, name.length() - 1) + "*"; + return new MemberInfo(id, loginId, maskedName, birthday, email); + } +} \ No newline at end of file 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..19fbd719 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,87 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +public class Member { + + private static final DateTimeFormatter BIRTHDAY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private Long id; + private String loginId; + private String password; + private String name; + private LocalDate birthday; + private String email; + + public Member(String loginId, String password, String name, LocalDate birthday, String email) { + validateBirthday(birthday); + validatePasswordNotContainsBirthday(password, birthday); + + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthday = birthday; + this.email = email; + } + + public Member(Long id, String loginId, String password, String name, LocalDate birthday, String email) { + this.id = id; + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthday = birthday; + this.email = email; + } + + public void encryptPassword(String encodedPassword) { + this.password = encodedPassword; + } + + public void changePassword(String newRawPassword, String newEncodedPassword) { + validatePasswordNotContainsBirthday(newRawPassword, this.birthday); + this.password = newEncodedPassword; + } + + private void validateBirthday(LocalDate birthday) { + if (birthday != null && birthday.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래 날짜일 수 없습니다."); + } + } + + private void validatePasswordNotContainsBirthday(String password, LocalDate birthday) { + if (password != null && birthday != null) { + String birthdayStr = birthday.format(BIRTHDAY_FORMATTER); + if (password.contains(birthdayStr)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } + } + + public Long getId() { + return id; + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public LocalDate getBirthday() { + return birthday; + } + + public String getEmail() { + return email; + } +} \ No newline at end of file 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..5fc68114 --- /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 java.util.Optional; + +public interface MemberRepository { + Member save(Member member); + boolean existsByLoginId(String loginId); + boolean existsByEmail(String email); + Optional findByLoginId(String loginId); + void updatePassword(String loginId, String encodedPassword); +} \ No newline at end of file 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..112785f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,61 @@ +package com.loopers.domain.member; + +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.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true) + public Member authenticate(String loginId, String rawPassword) { + Member member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED)); + + if (!passwordEncoder.matches(rawPassword, member.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED); + } + + return member; + } + + @Transactional + public Member signUp(String loginId, String password, String name, LocalDate birthday, String email) { + if (memberRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT); + } + if (memberRepository.existsByEmail(email)) { + throw new CoreException(ErrorType.CONFLICT); + } + + Member member = new Member(loginId, password, name, birthday, email); + member.encryptPassword(passwordEncoder.encode(password)); + return memberRepository.save(member); + } + + @Transactional + public void updatePassword(String loginId, String currentPassword, String newPassword) { + Member member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED)); + + if (!passwordEncoder.matches(currentPassword, member.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "현재 비밀번호가 일치하지 않습니다."); + } + if (passwordEncoder.matches(newPassword, member.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 달라야 합니다."); + } + + String encodedNewPassword = passwordEncoder.encode(newPassword); + member.changePassword(newPassword, encodedNewPassword); + memberRepository.updatePassword(loginId, member.getPassword()); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java new file mode 100644 index 00000000..74a75fc4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java @@ -0,0 +1,69 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.member.Member; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import java.time.LocalDate; + +@Entity +@Table(name = "member") +public class MemberEntity extends BaseEntity { + + @Column(name = "login_id", nullable = false, unique = true, length = 30) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "birthday", nullable = false) + private LocalDate birthday; + + @Column(name = "email", nullable = false, unique = true, length = 100) + private String email; + + protected MemberEntity() {} + + public static MemberEntity from(Member member) { + MemberEntity entity = new MemberEntity(); + entity.loginId = member.getLoginId(); + entity.password = member.getPassword(); + entity.name = member.getName(); + entity.birthday = member.getBirthday(); + entity.email = member.getEmail(); + return entity; + } + + public Member toDomain() { + return new Member(getId(), loginId, password, name, birthday, email); + } + + public void updatePassword(String password) { + this.password = password; + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public LocalDate getBirthday() { + return birthday; + } + + public String getEmail() { + return email; + } +} \ No newline at end of file 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..840bca72 --- /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 org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + boolean existsByLoginId(String loginId); + boolean existsByEmail(String email); + Optional findByLoginId(String loginId); +} \ No newline at end of file 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..090d714d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +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; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + MemberEntity entity = MemberEntity.from(member); + MemberEntity saved = memberJpaRepository.save(entity); + return saved.toDomain(); + } + + @Override + public boolean existsByLoginId(String loginId) { + return memberJpaRepository.existsByLoginId(loginId); + } + + @Override + public boolean existsByEmail(String email) { + return memberJpaRepository.existsByEmail(email); + } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginId(loginId) + .map(MemberEntity::toDomain); + } + + @Override + @Transactional + public void updatePassword(String loginId, String encodedPassword) { + MemberEntity entity = memberJpaRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED)); + entity.updatePassword(encodedPassword); + } +} \ No newline at end of file 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..a004f5ca 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,8 @@ 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.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -38,6 +40,13 @@ public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatc return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) { + String name = e.getHeaderName(); + String message = String.format("필수 요청 헤더 '%s'이(가) 누락되었습니다.", name); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(MissingServletRequestParameterException e) { String name = e.getParameterName(); @@ -46,6 +55,15 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getDefaultMessage()) + .orElse("입력값이 올바르지 않습니다."); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 00000000..faaec90b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Member V1 API", description = "회원 API 입니다.") +public interface MemberV1ApiSpec { + + @Operation( + summary = "회원가입", + description = "새로운 회원을 등록합니다." + ) + ApiResponse signUp(MemberV1Dto.SignUpRequest request); + + @Operation( + summary = "내 정보 조회", + description = "인증된 회원의 정보를 조회합니다." + ) + ApiResponse getMyInfo(String loginId, String password); + + @Operation( + summary = "비밀번호 변경", + description = "인증된 회원의 비밀번호를 변경합니다." + ) + ApiResponse updatePassword(String loginId, String password, MemberV1Dto.UpdatePasswordRequest request); +} \ No newline at end of file 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..57ed951e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.application.member.MemberInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller implements MemberV1ApiSpec { + + private final MemberFacade memberFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signUp( + @Valid @RequestBody MemberV1Dto.SignUpRequest request + ) { + LocalDate birthday = LocalDate.parse(request.birthday()); + MemberInfo info = memberFacade.signUp( + request.loginId(), + request.password(), + request.name(), + birthday, + request.email() + ); + MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/me") + @Override + public ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberInfo info = memberFacade.getMyInfo(loginId, password); + MemberV1Dto.MyInfoResponse response = MemberV1Dto.MyInfoResponse.from(info); + return ApiResponse.success(response); + } + + @PatchMapping("/me/password") + @Override + public ApiResponse updatePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @Valid @RequestBody MemberV1Dto.UpdatePasswordRequest request + ) { + if (!password.equals(request.currentPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "인증 정보가 일치하지 않습니다."); + } + memberFacade.updatePassword(loginId, request.currentPassword(), request.newPassword()); + return ApiResponse.success(); + } +} \ No newline at end of file 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..47bf6e00 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,67 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberInfo; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public class MemberV1Dto { + + public record SignUpRequest( + @NotBlank(message = "로그인 ID는 비어있을 수 없습니다.") + @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "로그인 ID는 영문과 숫자만 입력 가능합니다.") + String loginId, + + @NotBlank(message = "비밀번호는 비어있을 수 없습니다.") + @Size(min = 8, max = 16, message = "비밀번호는 8~16자여야 합니다.") + @Pattern(regexp = "^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]+$", message = "비밀번호는 영문 대소문자, 숫자, 특수문자만 입력 가능합니다.") + String password, + + @NotBlank(message = "이름은 비어있을 수 없습니다.") + @Pattern(regexp = "^[가-힣]{2,20}$", message = "이름은 한글 2~20자여야 합니다.") + String name, + + @NotBlank(message = "생년월일은 비어있을 수 없습니다.") + @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "생년월일 형식이 올바르지 않습니다. (yyyy-MM-dd)") + String birthday, + + @NotBlank(message = "이메일은 비어있을 수 없습니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email + ) { + } + + public record SignUpResponse(Long id, String loginId, String name, String email) { + public static SignUpResponse from(MemberInfo info) { + return new SignUpResponse( + info.id(), + info.loginId(), + info.name(), + info.email() + ); + } + } + + public record UpdatePasswordRequest( + @NotBlank(message = "현재 비밀번호는 비어있을 수 없습니다.") + String currentPassword, + + @NotBlank(message = "새 비밀번호는 비어있을 수 없습니다.") + @Size(min = 8, max = 16, message = "비밀번호는 8~16자여야 합니다.") + @Pattern(regexp = "^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]+$", message = "비밀번호는 영문 대소문자, 숫자, 특수문자만 입력 가능합니다.") + String newPassword + ) { + } + + public record MyInfoResponse(String loginId, String name, String birthday, String email) { + public static MyInfoResponse from(MemberInfo info) { + return new MyInfoResponse( + info.loginId(), + info.name(), + info.birthday().toString(), + info.email() + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/PasswordEncoderConfig.java new file mode 100644 index 00000000..60dcc143 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.support.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file 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..8d493491 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/application/member/MemberFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java new file mode 100644 index 00000000..9d5aae35 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java @@ -0,0 +1,99 @@ +package com.loopers.application.member; + +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 com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberFacadeTest { + + private final MemberFacade memberFacade; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberFacadeTest( + MemberFacade memberFacade, + MemberRepository memberRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.memberFacade = memberFacade; + this.memberRepository = memberRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입을 요청할 때,") + @Nested + class SignUp { + + @DisplayName("정상적인 정보로 가입하면, MemberInfo를 반환한다.") + @Test + void signUp_withValidInfo_returnsMemberInfo() { + // arrange + String loginId = "testuser1"; + String password = "Test1234!"; + String name = "홍길동"; + LocalDate birthday = LocalDate.of(1995, 3, 15); + String email = "test@example.com"; + + // act + MemberInfo info = memberFacade.signUp(loginId, password, name, birthday, email); + + // assert + assertAll( + () -> assertThat(info.id()).isNotNull(), + () -> assertThat(info.loginId()).isEqualTo(loginId), + () -> assertThat(info.name()).isEqualTo(name), + () -> assertThat(info.birthday()).isEqualTo(birthday), + () -> assertThat(info.email()).isEqualTo(email) + ); + } + } + + @DisplayName("내 정보를 조회할 때,") + @Nested + class GetMyInfo { + + @DisplayName("올바른 자격 증명으로 조회하면, MemberInfo를 반환한다.") + @Test + void getMyInfo_withValidCredentials_returnsMemberInfo() { + // arrange + String rawPassword = "Test1234!"; + Member member = new Member("testuser1", rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + memberRepository.save(member); + + // act + MemberInfo info = memberFacade.getMyInfo("testuser1", rawPassword); + + // assert + assertAll( + () -> assertThat(info.loginId()).isEqualTo("testuser1"), + () -> assertThat(info.name()).isEqualTo("홍길*"), + () -> assertThat(info.birthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(info.email()).isEqualTo("test@example.com") + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java new file mode 100644 index 00000000..a704ec10 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java @@ -0,0 +1,71 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MemberInfoTest { + + @DisplayName("MemberInfo 변환 시,") + @Nested + class From { + + @DisplayName("Member 도메인 객체로부터 MemberInfo를 생성하면, password를 제외한 정보를 포함한다.") + @Test + void createsMemberInfo_fromDomain_withoutPassword() { + // arrange + Member member = new Member(1L, "testuser1", "$2a$10$encodedHash", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + + // act + MemberInfo info = MemberInfo.from(member); + + // assert + assertAll( + () -> assertThat(info.id()).isEqualTo(1L), + () -> assertThat(info.loginId()).isEqualTo("testuser1"), + () -> assertThat(info.name()).isEqualTo("홍길동"), + () -> assertThat(info.birthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(info.email()).isEqualTo("test@example.com") + ); + } + } + + @DisplayName("이름 마스킹 시,") + @Nested + class MaskName { + + @DisplayName("3자 이상 이름이면, 마지막 글자가 *로 마스킹된다.") + @Test + void masksLastCharacter_whenNameHasThreeOrMoreCharacters() { + // arrange + Member member = new Member(1L, "testuser1", "$2a$10$encodedHash", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + MemberInfo info = MemberInfo.from(member); + + // act + MemberInfo masked = info.withMaskedName(); + + // assert + assertThat(masked.name()).isEqualTo("홍길*"); + } + + @DisplayName("2자 이름이면, 마지막 글자가 *로 마스킹된다.") + @Test + void masksLastCharacter_whenNameHasTwoCharacters() { + // arrange + Member member = new Member(1L, "testuser1", "$2a$10$encodedHash", "홍길", LocalDate.of(1995, 3, 15), "test@example.com"); + MemberInfo info = MemberInfo.from(member); + + // act + MemberInfo masked = info.withMaskedName(); + + // assert + assertThat(masked.name()).isEqualTo("홍*"); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java new file mode 100644 index 00000000..60d94f47 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java @@ -0,0 +1,212 @@ +package com.loopers.domain.member; + +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 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; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberServiceTest { + + private final MemberService memberService; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberServiceTest( + MemberService memberService, + MemberRepository memberRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.memberService = memberService; + this.memberRepository = memberRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입을 할 때,") + @Nested + class SignUp { + + @DisplayName("정상적인 정보로 가입하면, 회원이 저장되고 비밀번호가 암호화된다.") + @Test + void signUp_withValidInfo_savesMemberWithEncryptedPassword() { + // arrange + String loginId = "testuser1"; + String rawPassword = "Test1234!"; + String name = "홍길동"; + LocalDate birthday = LocalDate.of(1995, 3, 15); + String email = "test@example.com"; + + // act + Member savedMember = memberService.signUp(loginId, rawPassword, name, birthday, email); + + // assert + assertAll( + () -> assertThat(savedMember.getId()).isNotNull(), + () -> assertThat(savedMember.getLoginId()).isEqualTo(loginId), + () -> assertThat(savedMember.getName()).isEqualTo(name), + () -> assertThat(savedMember.getBirthday()).isEqualTo(birthday), + () -> assertThat(savedMember.getEmail()).isEqualTo(email), + () -> assertThat(savedMember.getPassword()).isNotEqualTo(rawPassword), + () -> assertThat(passwordEncoder.matches(rawPassword, savedMember.getPassword())).isTrue() + ); + } + + @DisplayName("이미 존재하는 loginId로 가입하면, CONFLICT 예외가 발생한다.") + @Test + void signUp_withDuplicateLoginId_throwsConflict() { + // arrange + Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode("Test1234!")); + memberRepository.save(existing); + + // act & assert + assertThatThrownBy(() -> memberService.signUp("testuser1", "Other1234!", "김철수", LocalDate.of(1990, 1, 1), "other@example.com")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.CONFLICT)); + } + + @DisplayName("이미 존재하는 email로 가입하면, CONFLICT 예외가 발생한다.") + @Test + void signUp_withDuplicateEmail_throwsConflict() { + // arrange + Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode("Test1234!")); + memberRepository.save(existing); + + // act & assert + assertThatThrownBy(() -> memberService.signUp("testuser2", "Other1234!", "김철수", LocalDate.of(1990, 1, 1), "test@example.com")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.CONFLICT)); + } + } + + @DisplayName("인증을 할 때,") + @Nested + class Authenticate { + + @DisplayName("올바른 loginId와 password로 인증하면, 회원을 반환한다.") + @Test + void authenticate_withValidCredentials_returnsMember() { + // arrange + String rawPassword = "Test1234!"; + Member existing = new Member("testuser1", rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode(rawPassword)); + memberRepository.save(existing); + + // act + Member result = memberService.authenticate("testuser1", rawPassword); + + // assert + assertAll( + () -> assertThat(result.getLoginId()).isEqualTo("testuser1"), + () -> assertThat(result.getName()).isEqualTo("홍길동"), + () -> assertThat(result.getEmail()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 loginId로 인증하면, UNAUTHORIZED 예외가 발생한다.") + @Test + void authenticate_withNonExistentLoginId_throwsUnauthorized() { + // act & assert + assertThatThrownBy(() -> memberService.authenticate("nonexistent", "Test1234!")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + + @DisplayName("비밀번호가 일치하지 않으면, UNAUTHORIZED 예외가 발생한다.") + @Test + void authenticate_withWrongPassword_throwsUnauthorized() { + // arrange + Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode("Test1234!")); + memberRepository.save(existing); + + // act & assert + assertThatThrownBy(() -> memberService.authenticate("testuser1", "Wrong1234!")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class UpdatePassword { + + private Member saveMember(String loginId, String rawPassword) { + Member member = new Member(loginId, rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + return memberRepository.save(member); + } + + @DisplayName("올바른 현재 비밀번호와 유효한 새 비밀번호로 변경하면, 새 비밀번호로 인증이 가능하다.") + @Test + void updatesPassword_whenValidCurrentAndNewPassword() { + // arrange + String currentPassword = "Test1234!"; + String newPassword = "NewPass123!"; + saveMember("testuser1", currentPassword); + + // act + memberService.updatePassword("testuser1", currentPassword, newPassword); + + // assert + Member authenticated = memberService.authenticate("testuser1", newPassword); + assertThat(authenticated.getLoginId()).isEqualTo("testuser1"); + } + + @DisplayName("존재하지 않는 loginId로 변경하면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsUnauthorized_whenLoginIdNotFound() { + // act & assert + assertThatThrownBy(() -> memberService.updatePassword("nonexistent", "Test1234!", "NewPass123!")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsUnauthorized_whenCurrentPasswordIsWrong() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act & assert + assertThatThrownBy(() -> memberService.updatePassword("testuser1", "Wrong1234!", "NewPass123!")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordSameAsCurrent() { + // arrange + String password = "Test1234!"; + saveMember("testuser1", password); + + // act & assert + assertThatThrownBy(() -> memberService.updatePassword("testuser1", password, password)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } +} \ No newline at end of file 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..b3799bdb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,149 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MemberTest { + + private static final String VALID_LOGIN_ID = "testuser1"; + private static final String VALID_PASSWORD = "Test1234!"; + private static final String VALID_NAME = "홍길동"; + private static final LocalDate VALID_BIRTHDAY = LocalDate.of(1995, 3, 15); + private static final String VALID_EMAIL = "test@example.com"; + + @DisplayName("회원을 생성할 때,") + @Nested + class Create { + + @DisplayName("모든 값이 유효하면, 정상적으로 생성된다.") + @Test + void createsMember_whenAllFieldsAreValid() { + // arrange & act + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // assert + assertAll( + () -> assertThat(member.getLoginId()).isEqualTo(VALID_LOGIN_ID), + () -> assertThat(member.getPassword()).isEqualTo(VALID_PASSWORD), + () -> assertThat(member.getName()).isEqualTo(VALID_NAME), + () -> assertThat(member.getBirthday()).isEqualTo(VALID_BIRTHDAY), + () -> assertThat(member.getEmail()).isEqualTo(VALID_EMAIL) + ); + } + } + + @DisplayName("비밀번호를 검증할 때,") + @Nested + class ValidatePassword { + + @DisplayName("생년월일(yyyyMMdd)이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordContainsBirthday() { + // arrange + LocalDate birthday = LocalDate.of(1995, 3, 15); + + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, "A19950315!", VALID_NAME, birthday, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("생년월일을 검증할 때,") + @Nested + class ValidateBirthday { + + @DisplayName("오늘 날짜(경계값)이면, 정상적으로 생성된다.") + @Test + void createsSuccessfully_whenBirthdayIsToday() { + // arrange + LocalDate today = LocalDate.now(); + + // act + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, today, VALID_EMAIL); + + // assert + assertThat(member.getBirthday()).isEqualTo(today); + } + + @DisplayName("미래 날짜이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBirthdayIsFuture() { + // arrange + LocalDate futureDate = LocalDate.now().plusDays(1); + + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, futureDate, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("비밀번호를 암호화할 때,") + @Nested + class EncryptPassword { + + @DisplayName("암호화된 비밀번호로 교체된다.") + @Test + void replacesPasswordWithEncoded() { + // arrange + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + String encodedPassword = "$2a$10$encodedPasswordHash"; + + // act + member.encryptPassword(encodedPassword); + + // assert + assertThat(member.getPassword()).isEqualTo(encodedPassword); + } + } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class ChangePassword { + + @DisplayName("유효한 새 비밀번호로 변경하면, 비밀번호가 변경된다.") + @Test + void changesPassword_whenNewPasswordIsValid() { + // arrange + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + String newEncodedPassword = "$2a$10$newEncodedPasswordHash"; + + // act + member.changePassword("NewPass123!", newEncodedPassword); + + // assert + assertThat(member.getPassword()).isEqualTo(newEncodedPassword); + } + + @DisplayName("새 비밀번호에 생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordContainsBirthday() { + // arrange + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + member.changePassword("A19950315!", "encoded") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberEntityTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberEntityTest.java new file mode 100644 index 00000000..d75929da --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberEntityTest.java @@ -0,0 +1,60 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MemberEntityTest { + + @DisplayName("MemberEntity 변환 시,") + @Nested + class Convert { + + @DisplayName("Member 도메인 객체로부터 MemberEntity를 생성한다.") + @Test + void createsMemberEntity_fromDomain() { + // arrange + Member member = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword("$2a$10$encodedHash"); + + // act + MemberEntity entity = MemberEntity.from(member); + + // assert + assertAll( + () -> assertThat(entity.getLoginId()).isEqualTo("testuser1"), + () -> assertThat(entity.getPassword()).isEqualTo("$2a$10$encodedHash"), + () -> assertThat(entity.getName()).isEqualTo("홍길동"), + () -> assertThat(entity.getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(entity.getEmail()).isEqualTo("test@example.com") + ); + } + + @DisplayName("MemberEntity를 Member 도메인 객체로 변환한다.") + @Test + void convertsToDomain() { + // arrange + Member member = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword("$2a$10$encodedHash"); + MemberEntity entity = MemberEntity.from(member); + + // act + Member domain = entity.toDomain(); + + // assert + assertAll( + () -> assertThat(domain.getLoginId()).isEqualTo("testuser1"), + () -> assertThat(domain.getPassword()).isEqualTo("$2a$10$encodedHash"), + () -> assertThat(domain.getName()).isEqualTo("홍길동"), + () -> assertThat(domain.getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(domain.getEmail()).isEqualTo("test@example.com") + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java new file mode 100644 index 00000000..dd0410bf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java @@ -0,0 +1,159 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +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 java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberRepositoryImplIntegrationTest { + + private final MemberRepository memberRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberRepositoryImplIntegrationTest( + MemberRepository memberRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.memberRepository = memberRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Member createMember(String loginId, String email) { + Member member = new Member(loginId, "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), email); + member.encryptPassword("$2a$10$encodedHash"); + return member; + } + + @DisplayName("회원을 저장할 때,") + @Nested + class Save { + + @DisplayName("정상적으로 저장되고, ID가 부여된다.") + @Test + void savesMember_andAssignsId() { + // arrange + Member member = createMember("testuser1", "test@example.com"); + + // act + Member saved = memberRepository.save(member); + + // assert + assertAll( + () -> assertThat(saved.getId()).isNotNull(), + () -> assertThat(saved.getLoginId()).isEqualTo("testuser1"), + () -> assertThat(saved.getPassword()).isEqualTo("$2a$10$encodedHash"), + () -> assertThat(saved.getName()).isEqualTo("홍길동"), + () -> assertThat(saved.getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(saved.getEmail()).isEqualTo("test@example.com") + ); + } + } + + @DisplayName("로그인 ID 중복을 확인할 때,") + @Nested + class ExistsByLoginId { + + @DisplayName("존재하는 loginId이면, true를 반환한다.") + @Test + void returnsTrue_whenLoginIdExists() { + // arrange + memberRepository.save(createMember("testuser1", "test@example.com")); + + // act + boolean result = memberRepository.existsByLoginId("testuser1"); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("존재하지 않는 loginId이면, false를 반환한다.") + @Test + void returnsFalse_whenLoginIdDoesNotExist() { + // act + boolean result = memberRepository.existsByLoginId("nonexistent"); + + // assert + assertThat(result).isFalse(); + } + } + + @DisplayName("로그인 ID로 회원을 조회할 때,") + @Nested + class FindByLoginId { + + @DisplayName("존재하는 loginId이면, 회원을 반환한다.") + @Test + void returnsMember_whenLoginIdExists() { + // arrange + memberRepository.save(createMember("testuser1", "test@example.com")); + + // act + Optional result = memberRepository.findByLoginId("testuser1"); + + // assert + assertAll( + () -> assertThat(result).isPresent(), + () -> assertThat(result.get().getLoginId()).isEqualTo("testuser1"), + () -> assertThat(result.get().getName()).isEqualTo("홍길동"), + () -> assertThat(result.get().getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(result.get().getEmail()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 loginId이면, 빈 Optional을 반환한다.") + @Test + void returnsEmpty_whenLoginIdDoesNotExist() { + // act + Optional result = memberRepository.findByLoginId("nonexistent"); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("이메일 중복을 확인할 때,") + @Nested + class ExistsByEmail { + + @DisplayName("존재하는 email이면, true를 반환한다.") + @Test + void returnsTrue_whenEmailExists() { + // arrange + memberRepository.save(createMember("testuser1", "test@example.com")); + + // act + boolean result = memberRepository.existsByEmail("test@example.com"); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("존재하지 않는 email이면, false를 반환한다.") + @Test + void returnsFalse_whenEmailDoesNotExist() { + // act + boolean result = memberRepository.existsByEmail("nonexistent@example.com"); + + // assert + assertThat(result).isFalse(); + } + } +} \ No newline at end of file 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..73a9f47d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java @@ -0,0 +1,406 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +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 org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MemberV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/members"; + + private final TestRestTemplate testRestTemplate; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberV1ApiE2ETest( + TestRestTemplate testRestTemplate, + MemberRepository memberRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.memberRepository = memberRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/members") + @Nested + class SignUp { + + @DisplayName("정상적인 정보로 가입하면, 201 Created와 회원 정보를 반환한다.") + @Test + void returnsCreated_whenValidRequest() { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Test1234!", "홍길동", "1995-03-15", "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().id()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("필수 필드가 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenFieldMissing() { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Test1234!", "홍길동", "1995-03-15", null + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("생년월일이 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenBirthdayMissing() { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Test1234!", "홍길동", null, "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("생년월일 형식이 잘못되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenBirthdayFormatInvalid() { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Test1234!", "홍길동", "19950315", "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("이미 존재하는 loginId로 가입하면, 409 Conflict를 반환한다.") + @Test + void returnsConflict_whenDuplicateLoginId() { + // arrange + Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode("Test1234!")); + memberRepository.save(existing); + + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Other1234!", "김철수", "1990-01-01", "other@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + } + + @DisplayName("GET /api/v1/members/me") + @Nested + class GetMyInfo { + + private Member saveMember(String loginId, String rawPassword) { + Member member = new Member(loginId, rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + return memberRepository.save(member); + } + + private HttpEntity createAuthHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return new HttpEntity<>(headers); + } + + @DisplayName("올바른 인증 정보로 조회하면, 200 OK와 회원 정보를 반환한다.") + @Test + void returnsOk_whenValidCredentials() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/me", HttpMethod.GET, createAuthHeaders("testuser1", "Test1234!"), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), + () -> assertThat(response.getBody().data().birthday()).isEqualTo("1995-03-15"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 loginId로 조회하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenLoginIdNotFound() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/me", HttpMethod.GET, createAuthHeaders("nonexistent", "Test1234!"), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("비밀번호가 일치하지 않으면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenPasswordWrong() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/me", HttpMethod.GET, createAuthHeaders("testuser1", "Wrong1234!"), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("인증 헤더가 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenHeaderMissing() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/me", HttpMethod.GET, null, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("PATCH /api/v1/members/me/password") + @Nested + class UpdatePassword { + + private Member saveMember(String loginId, String rawPassword) { + Member member = new Member(loginId, rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + return memberRepository.save(member); + } + + private HttpEntity createRequest( + String loginId, String headerPassword, String currentPassword, String newPassword + ) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", headerPassword); + headers.setContentType(MediaType.APPLICATION_JSON); + return new HttpEntity<>(new MemberV1Dto.UpdatePasswordRequest(currentPassword, newPassword), headers); + } + + @DisplayName("올바른 인증 정보와 유효한 새 비밀번호로 변경하면, 200 OK를 반환한다.") + @Test + void returnsOk_whenValidRequest() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Test1234!", "NewPass123!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("비밀번호 변경 후, 새 비밀번호로 내 정보 조회가 가능하다.") + @Test + void canLoginWithNewPassword_afterPasswordUpdate() { + // arrange + saveMember("testuser1", "Test1234!"); + testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Test1234!", "NewPass123!"), + new ParameterizedTypeReference>() {} + ); + + // act + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser1"); + headers.set("X-Loopers-LoginPw", "NewPass123!"); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me", HttpMethod.GET, new HttpEntity<>(headers), responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1") + ); + } + + @DisplayName("존재하지 않는 loginId로 변경하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenLoginIdNotFound() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("nonexistent", "Test1234!", "Test1234!", "NewPass123!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenCurrentPasswordWrong() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Wrong1234!", "Wrong1234!", "NewPass123!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("헤더 비밀번호와 Body currentPassword가 다르면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenHeaderPasswordMismatchesBody() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Different1!", "NewPass123!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenNewPasswordSameAsCurrent() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Test1234!", "Test1234!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 8자 미만이면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenNewPasswordTooShort() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Test1234!", "New12!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("인증 헤더가 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenHeaderMissing() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>( + new MemberV1Dto.UpdatePasswordRequest("Test1234!", "NewPass123!"), headers + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, request, responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/resources/docker-java.properties b/apps/commerce-api/src/test/resources/docker-java.properties new file mode 100644 index 00000000..e1af86b4 --- /dev/null +++ b/apps/commerce-api/src/test/resources/docker-java.properties @@ -0,0 +1 @@ +api.version=1.44 \ No newline at end of file diff --git a/docs/member-erd.md b/docs/member-erd.md new file mode 100644 index 00000000..b69baed5 --- /dev/null +++ b/docs/member-erd.md @@ -0,0 +1,34 @@ +# Member ERD + +## 회원 테이블 설계 + +```mermaid +erDiagram + MEMBER { + BIGINT id PK "AUTO_INCREMENT" + VARCHAR(30) login_id UK "NOT NULL, 로그인 ID" + VARCHAR(255) password "NOT NULL, 암호화 저장" + VARCHAR(50) name "NOT NULL, 이름" + DATE birthday "NOT NULL, 생년월일" + VARCHAR(100) email UK "NOT NULL, 이메일" + DATETIME created_at "NOT NULL, 생성일시" + DATETIME updated_at "NOT NULL, 수정일시" + DATETIME deleted_at "NULLABLE, 삭제일시" + } +``` + +## 제약조건 + +| 제약조건 | 대상 컬럼 | 설명 | +|----------|-----------|------| +| PRIMARY KEY | `id` | AUTO_INCREMENT | +| UNIQUE | `login_id` | 로그인 ID 중복 방지 | +| UNIQUE | `email` | 이메일 중복 방지 | +| NOT NULL | `login_id`, `password`, `name`, `birthday`, `email` | 필수 입력 | + +## 비고 + +- `password`는 BCrypt 등으로 암호화하여 저장 (VARCHAR 255) +- `birthday`는 `LocalDate` 매핑 (시분초 불필요) +- `created_at`, `updated_at`, `deleted_at`은 `BaseEntity`에서 자동 관리 +- `deleted_at`을 통한 소프트 삭제 지원 diff --git a/docs/member-profile-lookup-design.md b/docs/member-profile-lookup-design.md new file mode 100644 index 00000000..7e11db1f --- /dev/null +++ b/docs/member-profile-lookup-design.md @@ -0,0 +1,174 @@ +# 내 정보 조회 기능 설계 + +## 요청/응답 스펙 + +| 항목 | 값 | +|------|---| +| Method | GET | +| URL | `/api/v1/members/me` | +| 인증 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` 헤더 | +| 응답 코드 (성공) | 200 OK | +| 응답 코드 (인증 실패) | 401 Unauthorized | + +### 응답 필드 + +| 필드 | 타입 | 설명 | +|------|------|------| +| loginId | String | 로그인 ID | +| name | String | 이름 | +| birthday | String | 생년월일 (yyyy-MM-dd) | +| email | String | 이메일 | + +## 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + actor Client + participant Controller as MemberV1Controller + participant Facade as MemberFacade + participant Service as MemberService + participant Repository as MemberRepository + participant RepoImpl as MemberRepositoryImpl + participant Entity as MemberEntity (Persistence) + participant JpaRepo as MemberJpaRepository + participant PasswordEncoder as PasswordEncoder + + Client->>Controller: GET /api/v1/members/me
X-Loopers-LoginId / X-Loopers-LoginPw + + Controller->>Facade: getMyInfo(loginId, password) + + Note over Facade: 1. 회원 조회 + Facade->>Service: authenticate(loginId, password) + Service->>Repository: findByLoginId(loginId) + Repository->>RepoImpl: findByLoginId(loginId) + RepoImpl->>JpaRepo: findByLoginId(loginId) + JpaRepo-->>RepoImpl: Optional~MemberEntity~ + Note over RepoImpl: Persistence → Domain 변환 + RepoImpl->>Entity: toDomain() + Entity-->>RepoImpl: Member + RepoImpl-->>Service: Optional~Member~ + + alt 회원 미존재 + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized + end + + Note over Service: 2. 비밀번호 검증 + Service->>PasswordEncoder: matches(rawPassword, encodedPassword) + PasswordEncoder-->>Service: boolean + + alt 비밀번호 불일치 + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized + end + + Service-->>Facade: Member + + Note over Facade: Domain → Info 변환 + Facade-->>Controller: MemberInfo + Controller-->>Client: 200 OK (MyInfoResponse) +``` + +## 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% === Interfaces Layer === + class MemberV1Controller { + -MemberFacade memberFacade + +getMyInfo(loginId, password) ApiResponse~MyInfoResponse~ + } + + class MemberV1ApiSpec { + <> + +getMyInfo(loginId, password) ApiResponse~MyInfoResponse~ + } + + class MyInfoResponse { + <> + +String loginId + +String name + +String birthday + +String email + +from(MemberInfo) MyInfoResponse + } + + %% === Application Layer === + class MemberFacade { + -MemberService memberService + +getMyInfo(loginId, password) MemberInfo + } + + class MemberInfo { + <> + +Long id + +String loginId + +String name + +LocalDate birthday + +String email + +from(Member) MemberInfo + } + + %% === Domain Layer === + class Member { + <> + -Long id + -String loginId + -String password + -String name + -LocalDate birthday + -String email + } + + class MemberRepository { + <> + +findByLoginId(String) Optional~Member~ + } + + class MemberService { + -MemberRepository memberRepository + -PasswordEncoder passwordEncoder + +authenticate(loginId, password) Member + } + + %% === Infrastructure Layer === + class MemberEntity { + <> + -String loginId + -String password + -String name + -LocalDate birthday + -String email + +toDomain() Member + } + + class MemberRepositoryImpl { + -MemberJpaRepository memberJpaRepository + +findByLoginId(String) Optional~Member~ + } + + class MemberJpaRepository { + <> + +findByLoginId(String) Optional~MemberEntity~ + } + + %% === Relationships === + MemberV1ApiSpec <|.. MemberV1Controller + MemberV1Controller --> MemberFacade + MemberV1Controller ..> MyInfoResponse + MyInfoResponse ..> MemberInfo + + MemberFacade --> MemberService + MemberFacade ..> MemberInfo + MemberInfo ..> Member + + MemberService --> MemberRepository + MemberService ..> Member + + MemberRepository <|.. MemberRepositoryImpl + MemberRepositoryImpl --> MemberJpaRepository + MemberRepositoryImpl ..> MemberEntity + MemberRepositoryImpl ..> Member +``` \ No newline at end of file diff --git a/docs/member-signup-design.md b/docs/member-signup-design.md new file mode 100644 index 00000000..ec9cdf7b --- /dev/null +++ b/docs/member-signup-design.md @@ -0,0 +1,264 @@ +# 회원가입 기능 설계 + +## 검증 규칙 + +| 필드 | 규칙 | +|------|------| +| loginId | NOT NULL, NOT BLANK | +| password | 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용, 생년월일(yyyyMMdd) 포함 불가 | +| name | NOT NULL, NOT BLANK, 한글 2~20자 | +| birthday | NOT NULL, `yyyy-MM-dd` 형식, 미래 날짜 불가 | +| email | NOT NULL, 이메일 형식 (RFC 5322) | + +## 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + actor Client + participant Controller as MemberV1Controller + participant Facade as MemberFacade + participant Service as MemberService + participant Member as Member (Domain) + participant Repository as MemberRepository + participant RepoImpl as MemberRepositoryImpl + participant Entity as MemberEntity (Persistence) + participant JpaRepo as MemberJpaRepository + participant PasswordEncoder as PasswordEncoder + + Client->>Controller: POST /api/v1/members (SignUpRequest) + Controller->>Facade: signUp(request) + Facade->>Service: signUp(loginId, password, name, birthday, email) + + Note over Service: 1. loginId 중복 검사 + Service->>Repository: existsByLoginId(loginId) + Repository->>RepoImpl: existsByLoginId(loginId) + RepoImpl->>JpaRepo: existsByLoginId(loginId) + JpaRepo-->>RepoImpl: boolean + RepoImpl-->>Service: boolean + + alt loginId 중복 + Service-->>Controller: CoreException (CONFLICT) + Controller-->>Client: 409 Conflict + end + + Note over Service: 2. email 중복 검사 + Service->>Repository: existsByEmail(email) + Repository->>RepoImpl: existsByEmail(email) + RepoImpl->>JpaRepo: existsByEmail(email) + JpaRepo-->>RepoImpl: boolean + RepoImpl-->>Service: boolean + + alt email 중복 + Service-->>Controller: CoreException (CONFLICT) + Controller-->>Client: 409 Conflict + end + + Note over Service: 3. 도메인 객체 생성 (필드 검증은 생성자에서 수행) + Service->>Member: new Member(loginId, rawPassword, name, birthday, email) + Note over Member: 이름 형식 검증 (한글 2~20자)
생년월일 형식 검증 (미래 날짜 불가)
이메일 형식 검증
비밀번호 규칙 검증
(8~16자, 문자종류, 생년월일 포함 여부) + + alt 검증 실패 + Member-->>Service: CoreException (BAD_REQUEST) + Service-->>Controller: CoreException (BAD_REQUEST) + Controller-->>Client: 400 Bad Request + end + + Note over Service: 4. 비밀번호 암호화 + Service->>PasswordEncoder: encode(rawPassword) + PasswordEncoder-->>Service: encodedPassword + Service->>Member: encryptPassword(encodedPassword) + + Note over Service: 5. 저장 + Service->>Repository: save(member) + Repository->>RepoImpl: save(member) + Note over RepoImpl: Domain → Persistence 변환 + RepoImpl->>Entity: MemberEntity.from(member) + Entity-->>RepoImpl: MemberEntity + RepoImpl->>JpaRepo: save(memberEntity) + JpaRepo-->>RepoImpl: MemberEntity + Note over RepoImpl: Persistence → Domain 변환 + RepoImpl->>Entity: toDomain() + Entity-->>RepoImpl: Member + RepoImpl-->>Service: Member + + Service-->>Facade: Member + Note over Facade: Domain → Info 변환 + Facade-->>Controller: MemberInfo + Controller-->>Client: 201 Created (SignUpResponse) +``` + +## 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% === Interfaces Layer === + class MemberV1Controller { + -MemberFacade memberFacade + +signUp(SignUpRequest) ApiResponse~SignUpResponse~ + } + + class MemberV1ApiSpec { + <> + +signUp(SignUpRequest) ApiResponse~SignUpResponse~ + } + + class MemberV1Dto { + <> + } + + class SignUpRequest { + <> + +String loginId + +String password + +String name + +String birthday + +String email + } + + class SignUpResponse { + <> + +Long id + +String loginId + +String name + +String email + +from(MemberInfo) SignUpResponse + } + + %% === Application Layer === + class MemberFacade { + -MemberService memberService + +signUp(SignUpRequest) MemberInfo + } + + class MemberInfo { + <> + +Long id + +String loginId + +String name + +String email + +from(Member) MemberInfo + } + + %% === Domain Layer === + class Member { + <> + -Long id + -String loginId + -String password + -String name + -LocalDate birthday + -String email + +Member(loginId, rawPassword, name, birthday, email) + +encryptPassword(encodedPassword) + -validateLoginId(loginId) + -validatePassword(rawPassword, birthday) + -validateName(name) + -validateBirthday(birthday) + -validateEmail(email) + } + + class MemberRepository { + <> + +save(Member) Member + +existsByLoginId(String) boolean + +existsByEmail(String) boolean + } + + class MemberService { + -MemberRepository memberRepository + -PasswordEncoder passwordEncoder + +signUp(loginId, password, name, birthday, email) Member + } + + %% === Infrastructure Layer === + class MemberEntity { + <> + -String loginId + -String password + -String name + -LocalDate birthday + -String email + +from(Member)$ MemberEntity + +toDomain() Member + } + + class BaseEntity { + <> + -Long id + -ZonedDateTime createdAt + -ZonedDateTime updatedAt + -ZonedDateTime deletedAt + } + + class MemberRepositoryImpl { + -MemberJpaRepository memberJpaRepository + +save(Member) Member + +existsByLoginId(String) boolean + +existsByEmail(String) boolean + } + + class MemberJpaRepository { + <> + +existsByLoginId(String) boolean + +existsByEmail(String) boolean + } + + class JpaRepository~T~ { + <> + } + + %% === Relationships === + MemberV1ApiSpec <|.. MemberV1Controller + MemberV1Controller --> MemberFacade + MemberV1Controller ..> MemberV1Dto + MemberV1Dto *-- SignUpRequest + MemberV1Dto *-- SignUpResponse + SignUpResponse ..> MemberInfo + + MemberFacade --> MemberService + MemberFacade ..> MemberInfo + MemberInfo ..> Member + + MemberService --> MemberRepository + MemberService ..> Member + + MemberRepository <|.. MemberRepositoryImpl + MemberRepositoryImpl --> MemberJpaRepository + MemberRepositoryImpl ..> MemberEntity + MemberRepositoryImpl ..> Member + BaseEntity <|-- MemberEntity + JpaRepository~T~ <|-- MemberJpaRepository +``` + +## 패키지 구조 + +``` +com.loopers +├── interfaces/api/member/ +│ ├── MemberV1Controller ← REST 엔드포인트 +│ ├── MemberV1Dto ← SignUpRequest, SignUpResponse (record) +│ └── MemberV1ApiSpec ← Swagger 스펙 인터페이스 +├── application/member/ +│ ├── MemberFacade ← 유즈케이스 오케스트레이션 +│ └── MemberInfo ← 응답 변환용 record +├── domain/member/ +│ ├── Member ← 도메인 엔티티 (순수 Java 객체, 검증 로직 포함) +│ ├── MemberRepository ← 도메인 레포지토리 인터페이스 +│ └── MemberService ← 비즈니스 로직 (중복 검사, 암호화 위임) +└── infrastructure/member/ + ├── MemberEntity ← JPA 영속성 엔티티 (BaseEntity 상속) + ├── MemberRepositoryImpl ← 레포지토리 구현체 (Domain ↔ Entity 변환) + └── MemberJpaRepository ← Spring Data JPA 인터페이스 +``` + +## Domain Entity vs Persistence Entity 분리 + +| 구분 | Member (Domain) | MemberEntity (Persistence) | +|------|-----------------|---------------------------| +| 위치 | `domain/member/` | `infrastructure/member/` | +| 역할 | 비즈니스 검증 로직 | DB 영속화 | +| 상속 | 없음 (순수 Java 객체) | `BaseEntity` 상속 | +| JPA 어노테이션 | 없음 | `@Entity`, `@Table`, `@Column` | +| 변환 | - | `from(Member)`, `toDomain()` | diff --git a/http/commerce-api/member-v1.http b/http/commerce-api/member-v1.http new file mode 100644 index 00000000..b00d6462 --- /dev/null +++ b/http/commerce-api/member-v1.http @@ -0,0 +1,27 @@ +### 회원가입 +POST {{commerce-api}}/api/v1/members +Content-Type: application/json + +{ + "loginId": "testuser1", + "password": "Test1234!", + "name": "홍길동", + "birthday": "1995-03-15", + "email": "test@example.com" +} + +### 내 정보 조회 +GET {{commerce-api}}/api/v1/members/me +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 비밀번호 변경 +PATCH {{commerce-api}}/api/v1/members/me/password +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass123!" +} \ No newline at end of file