diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e..00000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## πŸ§ͺ Implementation Quest - -> μ§€μ •λœ **λ‹¨μœ„ ν…ŒμŠ€νŠΈ / 톡합 ν…ŒμŠ€νŠΈ / E2E ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€**λ₯Ό ν•„μˆ˜λ‘œ κ΅¬ν˜„ν•˜κ³ , λͺ¨λ“  ν…ŒμŠ€νŠΈλ₯Ό ν†΅κ³Όμ‹œν‚€λŠ” 것을 λͺ©ν‘œλ‘œ ν•©λ‹ˆλ‹€. - -### νšŒμ› κ°€μž… - -**🧱 λ‹¨μœ„ ν…ŒμŠ€νŠΈ** - -- [ ] ID κ°€ `영문 및 숫자 10자 이내` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. -- [ ] 이메일이 `xx@yy.zz` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. -- [ ] 생년월일이 `yyyy-MM-dd` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] νšŒμ› κ°€μž…μ‹œ User μ €μž₯이 μˆ˜ν–‰λœλ‹€. ( spy 검증 ) -- [ ] 이미 κ°€μž…λœ ID 둜 νšŒμ›κ°€μž… μ‹œλ„ μ‹œ, μ‹€νŒ¨ν•œλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] νšŒμ› κ°€μž…μ΄ 성곡할 경우, μƒμ„±λœ μœ μ € 정보λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] νšŒμ› κ°€μž… μ‹œμ— 성별이 없을 경우, `400 Bad Request` 응닡을 λ°˜ν™˜ν•œλ‹€. - -### λ‚΄ 정보 쑰회 - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•  경우, νšŒμ› 정보가 λ°˜ν™˜λœλ‹€. -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ 경우, null 이 λ°˜ν™˜λœλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] λ‚΄ 정보 μ‘°νšŒμ— 성곡할 경우, ν•΄λ‹Ήν•˜λŠ” μœ μ € 정보λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ID 둜 μ‘°νšŒν•  경우, `404 Not Found` 응닡을 λ°˜ν™˜ν•œλ‹€. - -### 포인트 쑰회 - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•  경우, 보유 ν¬μΈνŠΈκ°€ λ°˜ν™˜λœλ‹€. -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ 경우, null 이 λ°˜ν™˜λœλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] 포인트 μ‘°νšŒμ— 성곡할 경우, 보유 포인트λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] `X-USER-ID` 헀더가 없을 경우, `400 Bad Request` 응닡을 λ°˜ν™˜ν•œλ‹€. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..aaa06230 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,196 @@ +# CLAUDE.md + +이 νŒŒμΌμ€ Claude Codeκ°€ ν”„λ‘œμ νŠΈ μž‘μ—… μ‹œ μ°Έμ‘°ν•˜λŠ” μ»¨ν…μŠ€νŠΈ λ¬Έμ„œμž…λ‹ˆλ‹€. + +## ν”„λ‘œμ νŠΈ κ°œμš” + +**loopers-java-spring-template** - Loopersμ—μ„œ μ œκ³΅ν•˜λŠ” Spring + Java λ©€ν‹°λͺ¨λ“ˆ ν…œν”Œλ¦Ώ ν”„λ‘œμ νŠΈμž…λ‹ˆλ‹€. + +- **Group**: `com.loopers` +- **Language**: Java 21 +- **Build Tool**: Gradle 8.13 (Kotlin DSL) +- **Framework**: Spring Boot 3.4.4 + +## 기술 μŠ€νƒ + +### Core +| 기술 | 버전 | +|------|------| +| Java | 21 | +| Gradle | 8.13 | +| Spring Boot | 3.4.4 | +| Spring Cloud | 2024.0.1 | +| Spring Dependency Management | 1.1.7 | + +### Database & Cache +| 기술 | μš©λ„ | +|------|------| +| Spring Data JPA | ORM | +| QueryDSL (jakarta) | νƒ€μž… 세이프 쿼리 | +| MySQL | RDBMS | +| Spring Data Redis | μΊμ‹œ/μ„Έμ…˜ | +| Spring Kafka | λ©”μ‹œμ§€ 브둜컀 | + +### ν…ŒμŠ€νŠΈ +| 기술 | 버전 | +|------|------| +| Testcontainers | 2.0.2 | +| Spring MockK | 4.0.2 | +| Mockito | 5.14.0 | +| Instancio JUnit | 5.0.2 | +| JaCoCo | ν…ŒμŠ€νŠΈ 컀버리지 | + +### λ¬Έμ„œν™” & λͺ¨λ‹ˆν„°λ§ +| 기술 | 버전/μš©λ„ | +|------|----------| +| SpringDoc OpenAPI | 2.7.0 | +| Micrometer + Prometheus | λ©”νŠΈλ¦­ μˆ˜μ§‘ | +| Micrometer Tracing (Brave) | λΆ„μ‚° 좔적 | +| Logback Slack Appender | 1.6.1 | + +### μœ ν‹Έλ¦¬ν‹° +- Lombok +- Jackson (JSR310, Kotlin Module) + +## λͺ¨λ“ˆ ꡬ쑰 + +``` +Root +β”œβ”€β”€ apps (μ‹€ν–‰ κ°€λŠ₯ν•œ SpringBootApplication) +β”‚ β”œβ”€β”€ commerce-api # REST API μ„œλ²„ (Web + OpenAPI) +β”‚ β”œβ”€β”€ commerce-batch # Spring Batch μ• ν”Œλ¦¬μΌ€μ΄μ…˜ +β”‚ └── commerce-streamer # Kafka 기반 슀트리밍 μ• ν”Œλ¦¬μΌ€μ΄μ…˜ +β”‚ +β”œβ”€β”€ modules (μž¬μ‚¬μš© κ°€λŠ₯ν•œ Configuration) +β”‚ β”œβ”€β”€ jpa # JPA + QueryDSL + MySQL μ„€μ • +β”‚ β”œβ”€β”€ redis # Redis μ„€μ • +β”‚ └── kafka # Kafka μ„€μ • +β”‚ +└── supports (λΆ€κ°€ κΈ°λŠ₯ Add-on) + β”œβ”€β”€ jackson # Jackson 직렬화 μ„€μ • + β”œβ”€β”€ logging # λ‘œκΉ… + Slack Appender + └── monitoring # Actuator + Prometheus λ©”νŠΈλ¦­ +``` + +### λͺ¨λ“ˆ μ˜μ‘΄μ„± + +| App | 의쑴 λͺ¨λ“ˆ | +|-----|----------| +| commerce-api | jpa, redis, jackson, logging, monitoring | +| commerce-batch | jpa, redis, jackson, logging, monitoring | +| commerce-streamer | jpa, redis, kafka, jackson, logging, monitoring | + +## λΉŒλ“œ & μ‹€ν–‰ + +### λΉŒλ“œ +```bash +./gradlew build +``` + +### ν…ŒμŠ€νŠΈ +```bash +./gradlew test +``` + +ν…ŒμŠ€νŠΈ μ„€μ •: +- Timezone: `Asia/Seoul` +- Profile: `test` +- 병렬 μ‹€ν–‰: λΉ„ν™œμ„±ν™” (`maxParallelForks = 1`) + +### 둜컬 ν™˜κ²½ μ‹€ν–‰ +```bash +# 인프라 (MySQL, Redis, Kafka λ“±) +docker-compose -f ./docker/infra-compose.yml up + +# λͺ¨λ‹ˆν„°λ§ (Prometheus, Grafana) +docker-compose -f ./docker/monitoring-compose.yml up +``` + +Grafana: http://localhost:3000 (admin/admin) + +## μ»¨λ²€μ…˜ + +### λͺ¨λ“ˆ κ·œμΉ™ +- `apps`: BootJar ν™œμ„±ν™”, 일반 Jar λΉ„ν™œμ„±ν™” +- `modules`, `supports`: 일반 Jar ν™œμ„±ν™”, BootJar λΉ„ν™œμ„±ν™” +- `modules`, `supports`λŠ” `java-library` ν”ŒλŸ¬κ·ΈμΈ μ‚¬μš© +- ν…ŒμŠ€νŠΈ ν”½μŠ€μ²˜λŠ” `java-test-fixtures` ν”ŒλŸ¬κ·ΈμΈμœΌλ‘œ 관리 + +### 버전 관리 +- ν”„λ‘œμ νŠΈ 버전 λ―Έμ§€μ • μ‹œ Git short hash μ‚¬μš© +- μ˜μ‘΄μ„± 버전은 `gradle.properties`μ—μ„œ 쀑앙 관리 + +## μ£Όμš” 디렉토리 + +``` +/docker # Docker Compose 파일 +/http # HTTP μš”μ²­ ν…ŒμŠ€νŠΈ 파일 +/gradle # Gradle Wrapper +``` + +## 개발 μ»¨λ²€μ…˜ (Strict Rules) + +### Entity & Domain +- **Entity**: `@Setter` μ‚¬μš© κΈˆμ§€. λ³€κ²½ λ‘œμ§μ€ 도메인 λ©”μ„œλ“œ(예: `updatePassword()`)둜 κ΅¬ν˜„. +- **Lombok**: `@Getter`, `@NoArgsConstructor(access = AccessLevel.PROTECTED)` κΈ°λ³Έ μ‚¬μš©. +- **BaseEntity**: λͺ¨λ“  EntityλŠ” `com.loopers.domain.BaseEntity`λ₯Ό 상속받아 생성/μˆ˜μ • μ‹œκ°„μ„ 관리. +- **Validation**: μƒμ„±μž μ‹œμ μ— `CoreException`을 μ‚¬μš©ν•˜μ—¬ μœ νš¨μ„± 검증 μˆ˜ν–‰. + +### API & Exception +- **Response**: λͺ¨λ“  Controller 응닡은 `com.loopers.interfaces.api.ApiResponse`둜 κ°μ‹Έμ„œ λ°˜ν™˜. +- **Exception**: μ˜ˆμ™Έ λ°œμƒ μ‹œ `com.loopers.support.error.CoreException`κ³Ό `ErrorType`을 μ‚¬μš©. Java ν‘œμ€€ μ˜ˆμ™Έ(`IllegalArgumentException` λ“±) μ‚¬μš© μ§€μ–‘. + +### Coding Style +- **Null Safety**: `Optional`을 적극 ν™œμš©. `null`을 직접 λ°˜ν™˜ν•˜κ±°λ‚˜ νŒŒλΌλ―Έν„°λ‘œ λ°›μ§€ μ•ŠμŒ. +- **DI**: μƒμ„±μž μ£Όμž…(`@RequiredArgsConstructor`) μ‚¬μš©. Field Injection(`@Autowired`) κΈˆμ§€. + +## ν…ŒμŠ€νŠΈ μ „λž΅ (TDD Workflow) + +**λŒ€μ›μΉ™: Red(μ‹€νŒ¨) -> Green(κ΅¬ν˜„) -> Refactor(κ°œμ„ )** μˆœμ„œλ₯Ό λ°˜λ“œμ‹œ μ€€μˆ˜ν•œλ‹€. + +### 1. λ‹¨μœ„ ν…ŒμŠ€νŠΈ (Unit Test) +- **λŒ€μƒ**: Domain Entity, POJO +- **도ꡬ**: JUnit5, AssertJ +- **νŠΉμ§•**: Spring Context λ‘œλ”© κΈˆμ§€. 순수 μžλ°” μ½”λ“œλ‘œ 검증. +- **μœ„μΉ˜**: `apps/commerce-api/src/test/java/com/loopers/domain/**` + +### 2. 톡합 ν…ŒμŠ€νŠΈ (Integration Test) +- **λŒ€μƒ**: Service, Repository +- **도ꡬ**: `@SpringBootTest`, Testcontainers (MySQL) +- **κ·œμΉ™**: `com.loopers.utils.DatabaseCleanUp`을 μ‚¬μš©ν•˜μ—¬ λ§€ ν…ŒμŠ€νŠΈ μ’…λ£Œ ν›„ 데이터 μ΄ˆκΈ°ν™”. +- **μœ„μΉ˜**: `apps/commerce-api/src/test/java/com/loopers/application/**` + +### 3. E2E ν…ŒμŠ€νŠΈ (API Test) +- **λŒ€μƒ**: Controller (HTTP μš”μ²­/응닡) +- **도ꡬ**: `TestRestTemplate` +- **검증**: μ‹€μ œ HTTP Status Code와 `ApiResponse` λ³Έλ¬Έ 검증. + +## Round 1 Quest μš”κ΅¬μ‚¬ν•­ (Current Context) + +### 1. νšŒμ›κ°€μž… +- **ν•„μˆ˜ 정보**: ID, λΉ„λ°€λ²ˆν˜Έ, 이름, 생년월일, 이메일 +- **ID κ·œμΉ™**: 영문/숫자 μ‘°ν•© 10자 이내. 쀑볡 λΆˆκ°€. +- **λΉ„λ°€λ²ˆν˜Έ κ·œμΉ™**: + - 8~16자 + - 영문 λŒ€μ†Œλ¬Έμž, 숫자, 특수문자 ν•„μˆ˜ 포함 + - 생년월일 포함 λΆˆκ°€ + - μ•”ν˜Έν™”ν•˜μ—¬ μ €μž₯ ν•„μˆ˜ +- **μœ νš¨μ„± 검사**: 이메일 ν˜•μ‹, 생년월일(`yyyy-MM-dd`) ν˜•μ‹ 검증. + +### 2. λ‚΄ 정보 쑰회 +- **λ§ˆμŠ€ν‚Ή**: μ΄λ¦„μ˜ λ§ˆμ§€λ§‰ κΈ€μžλ₯Ό `*`둜 λ§ˆμŠ€ν‚Ήν•˜μ—¬ λ°˜ν™˜ (예: `홍길동` -> `홍길*`). + +### 3. λΉ„λ°€λ²ˆν˜Έ μˆ˜μ • +- ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 확인 ν›„ μƒˆ λΉ„λ°€λ²ˆν˜Έλ‘œ λ³€κ²½. +- κΈ°μ‘΄ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•œ λΉ„λ°€λ²ˆν˜Έ μ‚¬μš© λΆˆκ°€. + +## AI 페λ₯΄μ†Œλ‚˜ 및 행동 μ§€μΉ¨ +- **μ–Έμ–΄**: ν•œκ΅­μ–΄ (기술 μš©μ–΄λŠ” μ˜μ–΄ 병기 κ°€λŠ₯) +- **μš°μ„ μˆœμœ„**: + 1. μ‹€μ œ μ‹€ν–‰ κ°€λŠ₯ν•œ μ½”λ“œ 제곡 + 2. ν…ŒμŠ€νŠΈ μ½”λ“œ μš°μ„  μž‘μ„± (TDD) + 3. κΈ°μ‘΄ ν”„λ‘œμ νŠΈ ꡬ쑰(Multi-module) μ€€μˆ˜ +- **κΈˆμ§€μ‚¬ν•­**: + - `System.out.println` μ‚¬μš© κΈˆμ§€ (λ‘œκΉ…μ€ `@Slf4j` μ‚¬μš©) + - λΆˆν•„μš”ν•œ μ£Όμ„μ΄λ‚˜ μ„€λͺ…μœΌλ‘œ λ‹΅λ³€ 길게 ν•˜μ§€ 말 것. + - μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 라이브러리λ₯Ό μž„μ˜λ‘œ μΆ”κ°€ν•˜μ§€ 말 것. \ No newline at end of file diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..0fbb1f31 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -8,6 +8,9 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + + // security (for password encoding) + 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/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 00000000..c1899165 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,30 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.MemberService; +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; + +@Component +@RequiredArgsConstructor +public class MemberFacade { + + private final MemberService memberService; + private final MemberRepository memberRepository; + + @Transactional + public Member signup(MemberService.SignupCommand command) { + return memberService.signup(command); + } + + @Transactional + public void changePassword(Member member, String currentPassword, String newPassword) { + Member managedMember = memberRepository.findByMemberIdValue(member.getMemberId().getValue()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "νšŒμ›μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")); + memberService.changePassword(managedMember, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/DomainConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/DomainConfig.java new file mode 100644 index 00000000..057c3260 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/DomainConfig.java @@ -0,0 +1,16 @@ +package com.loopers.config; + +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.member.PasswordEncoder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DomainConfig { + + @Bean + public MemberService memberService(MemberRepository memberRepository, PasswordEncoder passwordEncoder) { + return new MemberService(memberRepository, passwordEncoder); + } +} 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..8d6c41d7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,55 @@ +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.MemberId; +import com.loopers.domain.member.vo.Name; +import com.loopers.domain.member.vo.Password; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "member") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseEntity { + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "member_id", nullable = false, unique = true)) + private MemberId memberId; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "password", nullable = false)) + private Password password; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "name", nullable = false)) + private Name name; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "email", nullable = false)) + private Email email; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "birth_date", nullable = false)) + private BirthDate birthDate; + + public Member(MemberId memberId, Password password, Name name, Email email, BirthDate birthDate) { + this.memberId = memberId; + this.password = password; + this.name = name; + this.email = email; + this.birthDate = birthDate; + } + + public void updatePassword(String currentPassword, String newPassword, PasswordEncoder encoder) { + this.password = this.password.change(currentPassword, newPassword, this.birthDate, encoder); + } +} 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..1550e6e3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + + Member save(Member member); + + Optional findByMemberIdValue(String memberIdValue); + + boolean existsByMemberIdValue(String memberIdValue); +} 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..87c94e73 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,51 @@ +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.MemberId; +import com.loopers.domain.member.vo.Name; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + public Member signup(SignupCommand command) { + MemberId memberId = new MemberId(command.memberId()); + BirthDate birthDate = new BirthDate(command.birthDate()); + + if (memberRepository.existsByMemberIdValue(memberId.getValue())) { + throw new CoreException(ErrorType.CONFLICT, "이미 μ‘΄μž¬ν•˜λŠ” νšŒμ› IDμž…λ‹ˆλ‹€."); + } + + Password.validate(command.password(), birthDate); + String encodedPassword = passwordEncoder.encode(command.password()); + + Member member = new Member( + memberId, + Password.ofEncoded(encodedPassword), + new Name(command.name()), + new Email(command.email()), + birthDate + ); + + return memberRepository.save(member); + } + + public void changePassword(Member member, String currentPassword, String newPassword) { + member.updatePassword(currentPassword, newPassword, passwordEncoder); + } + + public record SignupCommand( + String memberId, + String password, + String name, + String email, + String birthDate + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.java new file mode 100644 index 00000000..056131f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.java @@ -0,0 +1,8 @@ +package com.loopers.domain.member; + +public interface PasswordEncoder { + + String encode(String rawPassword); + + boolean matches(String rawPassword, String encodedPassword); +} 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..8e9e8d88 --- /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.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; + +@Getter +@EqualsAndHashCode +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BirthDate { + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd") + .withResolverStyle(ResolverStyle.STRICT); + + private LocalDate value; + + public BirthDate(String value) { + this.value = parse(value); + validateNotFuture(this.value); + } + + private LocalDate parse(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + + try { + return LocalDate.parse(value, FORMATTER); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. (yyyy-MM-dd)", e); + } + } + + private void validateNotFuture(LocalDate date) { + if (date.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래 λ‚ μ§œμΌ 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + } + + public String getFormattedValue() { + return value.format(FORMATTER); + } + + public String toPlainString() { + return getFormattedValue().replaceAll("-", ""); + } +} 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..5cc0626d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java @@ -0,0 +1,39 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.util.regex.Pattern; + +@Getter +@EqualsAndHashCode +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Email { + + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + ); + + private String value; + + public Email(String value) { + String normalized = value != null ? value.toLowerCase() : null; + validate(normalized); + this.value = normalized; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + + if (!EMAIL_PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.java new file mode 100644 index 00000000..885bfb63 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.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.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@EqualsAndHashCode +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberId { + + private static final int MIN_LENGTH = 4; + private static final int MAX_LENGTH = 10; + private static final String PATTERN = "^[a-zA-Z0-9]+$"; + + private String value; + + public MemberId(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "νšŒμ› IDλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + + if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "νšŒμ› IDλŠ” " + MIN_LENGTH + "~" + MAX_LENGTH + "μžμ—¬μ•Ό ν•©λ‹ˆλ‹€."); + } + + if (!value.matches(PATTERN)) { + throw new CoreException(ErrorType.BAD_REQUEST, "νšŒμ› IDλŠ” 영문과 숫자만 μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java new file mode 100644 index 00000000..429ea931 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java @@ -0,0 +1,43 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@EqualsAndHashCode +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Name { + + private static final int MAX_LENGTH = 20; + + private String value; + + public Name(String value) { + String trimmed = value != null ? value.trim() : null; + validate(trimmed); + this.value = trimmed; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + + if (value.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 " + MAX_LENGTH + "자 이내여야 ν•©λ‹ˆλ‹€."); + } + } + + public String masked() { + if (value.length() == 1) { + return "*"; + } + return value.substring(0, value.length() - 1) + "*"; + } +} 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..cc0de132 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java @@ -0,0 +1,88 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.PasswordEncoder; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@EqualsAndHashCode +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Password { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final String COMPLEXITY_REGEX = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).+$"; + + private String value; + + private Password(String encodedValue) { + this.value = encodedValue; + } + + public static void validate(String rawPassword, BirthDate birthDate) { + validatePasswordPolicy(rawPassword, birthDate); + } + + public static Password ofEncoded(String encodedValue) { + return new Password(encodedValue); + } + + private static void validatePasswordPolicy(String rawPassword, BirthDate birthDate) { + if (rawPassword == null || rawPassword.length() < MIN_LENGTH || rawPassword.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” " + MIN_LENGTH + "~" + MAX_LENGTH + "μžμ—¬μ•Ό ν•©λ‹ˆλ‹€."); + } + + if (!rawPassword.matches(COMPLEXITY_REGEX)) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” 영문, 숫자, 특수문자λ₯Ό 포함해야 ν•©λ‹ˆλ‹€."); + } + + checkBirthPatterns(rawPassword, birthDate); + } + + private static void checkBirthPatterns(String rawPassword, BirthDate birthDate) { + String plainBirth = birthDate.toPlainString(); + + String year = plainBirth.substring(0, 4); + String yy = plainBirth.substring(2, 4); + String mmdd = plainBirth.substring(4, 8); + + String[] blackListPatterns = { + year, + yy, + mmdd, + yy + mmdd, + year + mmdd + }; + + for (String pattern : blackListPatterns) { + if (rawPassword.contains(pattern)) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜Έμ— 생년월일과 κ΄€λ ¨λœ 숫자(" + pattern + ")λ₯Ό 포함할 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + } + } + + public boolean matches(String rawPassword, PasswordEncoder encoder) { + return encoder.matches(rawPassword, this.value); + } + + public Password change(String currentPassword, String newPassword, + BirthDate birthDate, PasswordEncoder encoder) { + if (!matches(currentPassword, encoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, "ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + Password.validate(newPassword, birthDate); + + if (matches(newPassword, encoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, "κΈ°μ‘΄ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•œ λΉ„λ°€λ²ˆν˜ΈλŠ” μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + return Password.ofEncoded(encoder.encode(newPassword)); + } +} 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..22cae84d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + + @Query("SELECT m FROM Member m WHERE m.memberId.value = :value") + Optional findByMemberIdValue(@Param("value") String value); + + @Query("SELECT CASE WHEN COUNT(m) > 0 THEN true ELSE false END FROM Member m WHERE m.memberId.value = :value") + boolean existsByMemberIdValue(@Param("value") String value); +} 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..20c12999 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,36 @@ +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.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + try { + return memberJpaRepository.save(member); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 μ‘΄μž¬ν•˜λŠ” νšŒμ› μ •λ³΄μž…λ‹ˆλ‹€.", e); + } + } + + @Override + public Optional findByMemberIdValue(String memberIdValue) { + return memberJpaRepository.findByMemberIdValue(memberIdValue); + } + + @Override + public boolean existsByMemberIdValue(String memberIdValue) { + return memberJpaRepository.existsByMemberIdValue(memberIdValue); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/security/BCryptPasswordEncoderAdapter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/security/BCryptPasswordEncoderAdapter.java new file mode 100644 index 00000000..e0c346af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/security/BCryptPasswordEncoderAdapter.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.security; + +import com.loopers.domain.member.PasswordEncoder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class BCryptPasswordEncoderAdapter implements PasswordEncoder { + + private final BCryptPasswordEncoder delegate = new BCryptPasswordEncoder(); + + @Override + public String encode(String rawPassword) { + return delegate.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return delegate.matches(rawPassword, encodedPassword); + } +} 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..2422b2e1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,41 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.domain.member.Member; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.resolver.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/members") +@RequiredArgsConstructor +public class MemberV1Controller { + + private final MemberFacade memberFacade; + + @PostMapping("/signup") + public ApiResponse signup(@RequestBody MemberV1Dto.SignupRequest request) { + Member member = memberFacade.signup(request.toCommand()); + return ApiResponse.success(MemberV1Dto.SignupResponse.from(member)); + } + + @GetMapping("/me") + public ApiResponse me(@LoginUser Member member) { + return ApiResponse.success(MemberV1Dto.MeResponse.from(member)); + } + + @PutMapping("/me/password") + public ApiResponse changePassword( + @LoginUser Member member, + @RequestBody MemberV1Dto.ChangePasswordRequest body + ) { + memberFacade.changePassword(member, body.currentPassword(), body.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..370a03d0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; + +public class MemberV1Dto { + + public record SignupRequest( + String memberId, + String password, + String name, + String email, + String birthDate + ) { + public MemberService.SignupCommand toCommand() { + return new MemberService.SignupCommand( + memberId, + password, + name, + email, + birthDate + ); + } + } + + public record SignupResponse( + String memberId, + String name, + String email + ) { + public static SignupResponse from(Member member) { + return new SignupResponse( + member.getMemberId().getValue(), + member.getName().getValue(), + member.getEmail().getValue() + ); + } + } + + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} + + public record MeResponse( + String memberId, + String name, + String email, + String birthDate + ) { + public static MeResponse from(Member member) { + return new MeResponse( + member.getMemberId().getValue(), + member.getName().masked(), + member.getEmail().getValue(), + member.getBirthDate().getFormattedValue() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java new file mode 100644 index 00000000..6faafcdd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.config; + +import com.loopers.interfaces.resolver.LoginUserArgumentResolver; +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; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final LoginUserArgumentResolver loginUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginUserArgumentResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/filter/AuthenticationFilter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/filter/AuthenticationFilter.java new file mode 100644 index 00000000..c93814d7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/filter/AuthenticationFilter.java @@ -0,0 +1,104 @@ +package com.loopers.interfaces.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.PasswordEncoder; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.Set; + +@Component +@Order(1) +@RequiredArgsConstructor +public class AuthenticationFilter implements Filter { + + private static final String LOGIN_ID_HEADER = "X-Loopers-LoginId"; + private static final String LOGIN_PW_HEADER = "X-Loopers-LoginPw"; + + private static final Set PUBLIC_PATHS = Set.of( + "/api/v1/members/signup", + "/api/v1/examples" + ); + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final ObjectMapper objectMapper; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + + String path = httpRequest.getRequestURI(); + + if (isPublicPath(path)) { + chain.doFilter(request, response); + return; + } + + if (!path.startsWith("/api/")) { + chain.doFilter(request, response); + return; + } + + String loginId = httpRequest.getHeader(LOGIN_ID_HEADER); + String loginPw = httpRequest.getHeader(LOGIN_PW_HEADER); + + if (loginId == null || loginPw == null) { + sendUnauthorizedResponse(httpResponse, "인증 정보가 ν•„μš”ν•©λ‹ˆλ‹€."); + return; + } + + Optional memberOpt = memberRepository.findByMemberIdValue(loginId); + if (memberOpt.isEmpty()) { + sendUnauthorizedResponse(httpResponse, "인증에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); + return; + } + + Member member = memberOpt.get(); + if (!member.getPassword().matches(loginPw, passwordEncoder)) { + sendUnauthorizedResponse(httpResponse, "인증에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); + return; + } + + httpRequest.setAttribute("authenticatedMember", member); + chain.doFilter(request, response); + } + + private boolean isPublicPath(String path) { + return PUBLIC_PATHS.stream().anyMatch(publicPath -> isPathMatch(path, publicPath)); + } + + private boolean isPathMatch(String requestPath, String publicPath) { + if (requestPath.equals(publicPath)) { + return true; + } + return requestPath.startsWith(publicPath + "/"); + } + + private void sendUnauthorizedResponse(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + + ApiResponse apiResponse = ApiResponse.fail(HttpStatus.UNAUTHORIZED.getReasonPhrase(), message); + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/resolver/LoginUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/resolver/LoginUser.java new file mode 100644 index 00000000..12302400 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/resolver/LoginUser.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.resolver; + +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 LoginUser { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/resolver/LoginUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/resolver/LoginUserArgumentResolver.java new file mode 100644 index 00000000..34e4c182 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/resolver/LoginUserArgumentResolver.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.resolver; + +import com.loopers.domain.member.Member; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.NonNull; +import org.springframework.core.MethodParameter; +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; + +@Component +public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginUser.class) + && Member.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(@NonNull MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + Object attribute = request.getAttribute("authenticatedMember"); + + if (!(attribute instanceof Member)) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 정보가 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + return attribute; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java index 0cc190b6..cb7d6aaf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java @@ -8,11 +8,15 @@ public class CoreException extends RuntimeException { private final String customMessage; public CoreException(ErrorType errorType) { - this(errorType, null); + this(errorType, null, null); } public CoreException(ErrorType errorType, String customMessage) { - super(customMessage != null ? customMessage : errorType.getMessage()); + this(errorType, customMessage, null); + } + + public CoreException(ErrorType errorType, String customMessage, Throwable cause) { + super(customMessage != null ? customMessage : errorType.getMessage(), cause); this.errorType = errorType; this.customMessage = customMessage; } 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/member/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java new file mode 100644 index 00000000..c91830d9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java @@ -0,0 +1,103 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.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.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class MemberServiceTest { + + private MemberService memberService; + private MemberRepository memberRepository; + private PasswordEncoder passwordEncoder; + + @BeforeEach + void setUp() { + memberRepository = mock(MemberRepository.class); + passwordEncoder = mock(PasswordEncoder.class); + memberService = new MemberService(memberRepository, passwordEncoder); + } + + @DisplayName("νšŒμ›κ°€μž…μ— μ„±κ³΅ν•˜λ©΄ μ €μž₯된 νšŒμ›μ„ λ°˜ν™˜ν•œλ‹€.") + @Test + void signup_success() { + // given + MemberService.SignupCommand command = new MemberService.SignupCommand( + "user1", + "Password1!", + "홍길동", + "test@test.com", + "1997-01-01" + ); + + given(memberRepository.existsByMemberIdValue(anyString())).willReturn(false); + given(passwordEncoder.encode(anyString())).willReturn("encodedPassword"); + given(memberRepository.save(any(Member.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + Member member = memberService.signup(command); + + // then + assertThat(member).isNotNull(); + assertThat(member.getMemberId().getValue()).isEqualTo("user1"); + verify(memberRepository).save(any(Member.class)); + } + + @DisplayName("이미 μ‘΄μž¬ν•˜λŠ” ID둜 νšŒμ›κ°€μž…ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void signup_fail_duplicate_id() { + // given + MemberService.SignupCommand command = new MemberService.SignupCommand( + "existing1", + "Password1!", + "홍길동", + "test@test.com", + "1997-01-01" + ); + + given(memberRepository.existsByMemberIdValue(anyString())).willReturn(true); + + // when & then + assertThatThrownBy(() -> memberService.signup(command)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("νšŒμ›κ°€μž… μ‹œ λΉ„λ°€λ²ˆν˜ΈλŠ” μ•”ν˜Έν™”λ˜μ–΄ μ €μž₯λœλ‹€.") + @Test + void signup_password_encoded() { + // given + String rawPassword = "Password1!"; + String encodedPassword = "$2a$10$encodedPasswordValue"; + + MemberService.SignupCommand command = new MemberService.SignupCommand( + "user1", + rawPassword, + "홍길동", + "test@test.com", + "1997-01-01" + ); + + given(memberRepository.existsByMemberIdValue(anyString())).willReturn(false); + given(passwordEncoder.encode(rawPassword)).willReturn(encodedPassword); + given(memberRepository.save(any(Member.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + Member member = memberService.signup(command); + + // then + assertThat(member.getPassword().getValue()).isEqualTo(encodedPassword); + verify(passwordEncoder).encode(rawPassword); + } +} 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..63de977c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,135 @@ +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.MemberId; +import com.loopers.domain.member.vo.Name; +import com.loopers.domain.member.vo.Password; +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.assertj.core.api.Assertions.assertThatThrownBy; + +class MemberTest { + + private final PasswordEncoder fakeEncoder = new PasswordEncoder() { + @Override + public String encode(String rawPassword) { + return "encoded:" + rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encodedPassword.equals("encoded:" + rawPassword); + } + }; + + @DisplayName("νšŒμ› 생성 μ‹œ 각 ν•„λ“œμ˜ 검증 λ‘œμ§μ€ VOμ—κ²Œ μœ„μž„ν•œλ‹€.") + @Test + void create_member_success() { + // given + MemberId memberId = new MemberId("user1"); + BirthDate birthDate = new BirthDate("1997-01-01"); + Password.validate("Valid123!", birthDate); + Password password = Password.ofEncoded(fakeEncoder.encode("Valid123!")); + Name name = new Name("μ•€λ“œλ₯˜"); + Email email = new Email("test@test.com"); + + // when + Member member = new Member(memberId, password, name, email, birthDate); + + // then + assertThat(member).isNotNull(); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ λ³€κ²½") + @Nested + class UpdatePassword { + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜λ©΄ μƒˆ λΉ„λ°€λ²ˆν˜Έλ‘œ λ³€κ²½λœλ‹€.") + @Test + void updatePassword_success() { + // given + String currentRaw = "OldPass123!"; + String newRaw = "NewPass456!"; + BirthDate birthDate = new BirthDate("1997-01-01"); + Member member = new Member( + new MemberId("user1"), + Password.ofEncoded(fakeEncoder.encode(currentRaw)), + new Name("μ•€λ“œλ₯˜"), + new Email("test@test.com"), + birthDate + ); + + // when + member.updatePassword(currentRaw, newRaw, fakeEncoder); + + // then + assertThat(member.getPassword().matches(newRaw, fakeEncoder)).isTrue(); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμœΌλ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void updatePassword_fail_wrongCurrentPassword() { + // given + String currentRaw = "OldPass123!"; + BirthDate birthDate = new BirthDate("1997-01-01"); + Member member = new Member( + new MemberId("user1"), + Password.ofEncoded(fakeEncoder.encode(currentRaw)), + new Name("μ•€λ“œλ₯˜"), + new Email("test@test.com"), + birthDate + ); + + // when & then + assertThatThrownBy(() -> member.updatePassword("WrongPass1!", "NewPass456!", fakeEncoder)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ 정책을 μœ„λ°˜ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void updatePassword_fail_invalidNewPassword() { + // given + String currentRaw = "OldPass123!"; + BirthDate birthDate = new BirthDate("1997-01-01"); + Member member = new Member( + new MemberId("user1"), + Password.ofEncoded(fakeEncoder.encode(currentRaw)), + new Name("μ•€λ“œλ₯˜"), + new Email("test@test.com"), + birthDate + ); + + // when & then - 특수문자 μ—†λŠ” λΉ„λ°€λ²ˆν˜Έ + assertThatThrownBy(() -> member.updatePassword(currentRaw, "NewPass456", fakeEncoder)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ κΈ°μ‘΄κ³Ό λ™μΌν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void updatePassword_fail_samePassword() { + // given + String currentRaw = "OldPass123!"; + BirthDate birthDate = new BirthDate("1997-01-01"); + Member member = new Member( + new MemberId("user1"), + Password.ofEncoded(fakeEncoder.encode(currentRaw)), + new Name("μ•€λ“œλ₯˜"), + new Email("test@test.com"), + birthDate + ); + + // when & then + assertThatThrownBy(() -> member.updatePassword(currentRaw, currentRaw, fakeEncoder)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } +} 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..82ecb0b3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java @@ -0,0 +1,101 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BirthDateTest { + + @DisplayName("yyyy-MM-dd ν˜•μ‹μ˜ 생년월일은 생성에 μ„±κ³΅ν•œλ‹€.") + @Test + void create_success() { + // given + String birth = "1997-01-15"; + + // when + BirthDate birthDate = new BirthDate(birth); + + // then + assertThat(birthDate.getFormattedValue()).isEqualTo(birth); + } + + @DisplayName("잘λͺ»λœ ν˜•μ‹μ˜ 생년월일은 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"19970115", "1997/01/15", "01-15-1997", "1997-1-15", "1997-01-1"}) + void create_fail_invalid_format(String invalidBirth) { + assertThatThrownBy(() -> new BirthDate(invalidBirth)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null λ˜λŠ” 빈 λ¬Έμžμ—΄μ€ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void create_fail_empty(String emptyBirth) { + assertThatThrownBy(() -> new BirthDate(emptyBirth)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null은 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void create_fail_null() { + assertThatThrownBy(() -> new BirthDate(null)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("μœ νš¨ν•˜μ§€ μ•Šμ€ λ‚ μ§œλŠ” μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"1997-13-01", "1997-02-30", "1997-00-15"}) + void create_fail_invalid_date(String invalidDate) { + assertThatThrownBy(() -> new BirthDate(invalidDate)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("숫자만 ν¬ν•¨λœ λ¬Έμžμ—΄(yyyyMMdd)을 λ°˜ν™˜ν•œλ‹€.") + @Test + void toPlainString() { + // given + BirthDate birthDate = new BirthDate("1997-01-15"); + + // when + String plain = birthDate.toPlainString(); + + // then + assertThat(plain).isEqualTo("19970115"); + } + + @DisplayName("미래 λ‚ μ§œλŠ” μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void create_fail_future_date() { + // given + String futureDate = LocalDate.now().plusDays(1).format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + + // when & then + assertThatThrownBy(() -> new BirthDate(futureDate)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("였늘 λ‚ μ§œλŠ” 생성에 μ„±κ³΅ν•œλ‹€.") + @Test + void create_success_today() { + // given + String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + + // when + BirthDate birthDate = new BirthDate(today); + + // then + assertThat(birthDate.getFormattedValue()).isEqualTo(today); + } +} 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..42750064 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java @@ -0,0 +1,51 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EmailTest { + + @DisplayName("μœ νš¨ν•œ 이메일 ν˜•μ‹μ€ 생성에 μ„±κ³΅ν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"test@test.com", "user.name@domain.co.kr", "user+tag@example.org"}) + void create_success(String validEmail) { + // when + Email email = new Email(validEmail); + + // then + assertThat(email.getValue()).isEqualTo(validEmail); + } + + @DisplayName("잘λͺ»λœ 이메일 ν˜•μ‹μ€ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"invalid", "invalid@", "@domain.com", "invalid@domain", "invalid @domain.com"}) + void create_fail_invalid_format(String invalidEmail) { + assertThatThrownBy(() -> new Email(invalidEmail)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null λ˜λŠ” 빈 λ¬Έμžμ—΄μ€ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void create_fail_empty(String emptyEmail) { + assertThatThrownBy(() -> new Email(emptyEmail)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null은 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void create_fail_null() { + assertThatThrownBy(() -> new Email(null)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java new file mode 100644 index 00000000..d506a119 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java @@ -0,0 +1,63 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MemberIdTest { + + @DisplayName("영문/숫자 μ‘°ν•© 10자 μ΄λ‚΄μ˜ IDλŠ” 생성에 μ„±κ³΅ν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"user1", "User123", "abcd1234", "ABCD123456"}) + void create_success(String validId) { + // when + MemberId memberId = new MemberId(validId); + + // then + assertThat(memberId.getValue()).isEqualTo(validId); + } + + @DisplayName("10자λ₯Ό μ΄ˆκ³Όν•˜λŠ” IDλŠ” μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void create_fail_too_long() { + // given + String tooLongId = "abcdefghijk"; + + // when & then + assertThatThrownBy(() -> new MemberId(tooLongId)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("영문/숫자 μ™Έμ˜ λ¬Έμžκ°€ ν¬ν•¨λ˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"user@1", "user-1", "user_1", "user 1", "μœ μ €1"}) + void create_fail_invalid_chars(String invalidId) { + assertThatThrownBy(() -> new MemberId(invalidId)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null λ˜λŠ” 빈 λ¬Έμžμ—΄μ€ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void create_fail_empty(String emptyId) { + assertThatThrownBy(() -> new MemberId(emptyId)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null은 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void create_fail_null() { + assertThatThrownBy(() -> new MemberId(null)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/NameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/NameTest.java new file mode 100644 index 00000000..4f0ab3d7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/NameTest.java @@ -0,0 +1,62 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class NameTest { + + @DisplayName("μœ νš¨ν•œ 이름은 생성에 μ„±κ³΅ν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"홍길동", "κΉ€", "Andrew", "κΉ€μ•€λ“œλ₯˜"}) + void create_success(String validName) { + // when + Name name = new Name(validName); + + // then + assertThat(name.getValue()).isEqualTo(validName); + } + + @DisplayName("null λ˜λŠ” 빈 λ¬Έμžμ—΄μ€ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void create_fail_empty(String emptyName) { + assertThatThrownBy(() -> new Name(emptyName)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null은 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void create_fail_null() { + assertThatThrownBy(() -> new Name(null)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("μ΄λ¦„μ˜ λ§ˆμ§€λ§‰ κΈ€μžλ₯Ό λ§ˆμŠ€ν‚Ήν•˜μ—¬ λ°˜ν™˜ν•œλ‹€.") + @ParameterizedTest + @CsvSource({ + "홍길동, 홍길*", + "κΉ€, *", + "Andrew, Andre*", + "AB, A*" + }) + void masked(String original, String expected) { + // given + Name name = new Name(original); + + // when + String masked = name.masked(); + + // then + assertThat(masked).isEqualTo(expected); + } +} 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..1fb3b463 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java @@ -0,0 +1,164 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.PasswordEncoder; +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 org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PasswordTest { + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ μ •μ±…(8~16자, 영문/숫자/특수문자 포함)을 μ€€μˆ˜ν•˜λ©΄ 검증에 μ„±κ³΅ν•œλ‹€.") + @Test + void validate_success() { + // given + String pw = "PassWord123!"; + BirthDate birthDate = new BirthDate("1997-01-01"); + + // when & then - μ˜ˆμ™Έκ°€ λ°œμƒν•˜μ§€ μ•ŠμœΌλ©΄ 성곡 + Password.validate(pw, birthDate); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ 길이가 8자 λ―Έλ§Œμ΄κ±°λ‚˜, 16자 초과면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"Short1!", "TooooooooooooLongPassword123!"}) + void create_fail_length(String invalidPw) { + // given + BirthDate birthDate = new BirthDate("1997-01-01"); + + // when & then + assertThatThrownBy(() -> Password.validate(invalidPw, birthDate)) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— 영문, 숫자, νŠΉμˆ˜λ¬Έμžκ°€ λͺ¨λ‘ ν¬ν•¨λ˜μ§€ μ•ŠμœΌλ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"Password123", "Password!@#", "12345678!@#"}) + void create_fail_complexity(String invalidPw) { + // given + BirthDate birthDate = new BirthDate("1997-01-01"); + + // when & then + assertThatThrownBy(() -> Password.validate(invalidPw, birthDate)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문, 숫자, 특수문자"); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— 생년월일 νŒ¨ν„΄(YYYY, YY, MMDD)이 ν¬ν•¨λ˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @CsvSource({ + "1997-12-31, Pass1997!@#", + "1997-12-31, Pass97!@#abc", + "1997-12-31, Pass1231!@#", + "1997-12-31, Pass971231!@#" + }) + void create_fail_contains_birth_pattern(String birth, String invalidPw) { + // given + BirthDate birthDate = new BirthDate(birth); + + // when & then + assertThatThrownBy(() -> Password.validate(invalidPw, birthDate)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("생년월일"); + } + + @DisplayName("이미 μ•”ν˜Έν™”λœ λΉ„λ°€λ²ˆν˜Έλ‘œ Password 객체λ₯Ό 생성할 수 μžˆλ‹€.") + @Test + void create_with_encoded_value() { + // given + String encodedValue = "$2a$10$someEncodedPasswordValue"; + + // when + Password password = Password.ofEncoded(encodedValue); + + // then + assertThat(password.getValue()).isEqualTo(encodedValue); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ ν…ŒμŠ€νŠΈ") + @Nested + class ChangePasswordTest { + + private final PasswordEncoder fakeEncoder = new PasswordEncoder() { + @Override + public String encode(String rawPassword) { + return "encoded:" + rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encodedPassword.equals("encoded:" + rawPassword); + } + }; + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜κ³  μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ μœ νš¨ν•˜λ©΄ 변경에 μ„±κ³΅ν•œλ‹€.") + @Test + void change_success() { + // given + String currentRaw = "OldPass123!"; + String newRaw = "NewPass456!"; + BirthDate birthDate = new BirthDate("1997-01-01"); + Password password = Password.ofEncoded(fakeEncoder.encode(currentRaw)); + + // when + Password changed = password.change(currentRaw, newRaw, birthDate, fakeEncoder); + + // then + assertThat(changed.matches(newRaw, fakeEncoder)).isTrue(); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμœΌλ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void change_fail_wrong_current_password() { + // given + String currentRaw = "OldPass123!"; + String wrongCurrent = "WrongPass1!"; + String newRaw = "NewPass456!"; + BirthDate birthDate = new BirthDate("1997-01-01"); + Password password = Password.ofEncoded(fakeEncoder.encode(currentRaw)); + + // when & then + assertThatThrownBy(() -> password.change(wrongCurrent, newRaw, birthDate, fakeEncoder)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€"); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ κΈ°μ‘΄ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void change_fail_same_password() { + // given + String currentRaw = "OldPass123!"; + String newRaw = "OldPass123!"; + BirthDate birthDate = new BirthDate("1997-01-01"); + Password password = Password.ofEncoded(fakeEncoder.encode(currentRaw)); + + // when & then + assertThatThrownBy(() -> password.change(currentRaw, newRaw, birthDate, fakeEncoder)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("κΈ°μ‘΄ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•œ λΉ„λ°€λ²ˆν˜ΈλŠ” μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€"); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ 정책을 μœ„λ°˜ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void change_fail_invalid_new_password() { + // given + String currentRaw = "OldPass123!"; + String newRaw = "short1!"; + BirthDate birthDate = new BirthDate("1997-01-01"); + Password password = Password.ofEncoded(fakeEncoder.encode(currentRaw)); + + // when & then + assertThatThrownBy(() -> password.change(currentRaw, newRaw, birthDate, fakeEncoder)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("8~16자"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java new file mode 100644 index 00000000..10746c57 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -0,0 +1,395 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.PasswordEncoder; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.MemberId; +import com.loopers.domain.member.vo.Name; +import com.loopers.domain.member.vo.Password; +import com.loopers.interfaces.api.member.MemberV1Dto; +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 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 SIGNUP_ENDPOINT = "/api/v1/members/signup"; + private static final String ME_ENDPOINT = "/api/v1/members/me"; + private static final String CHANGE_PASSWORD_ENDPOINT = "/api/v1/members/me/password"; + + 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/signup") + @Nested + class Signup { + + @DisplayName("μœ νš¨ν•œ νšŒμ› μ •λ³΄λ‘œ νšŒμ›κ°€μž…ν•˜λ©΄ μ„±κ³΅ν•œλ‹€.") + @Test + void signup_success() { + // arrange + MemberV1Dto.SignupRequest request = new MemberV1Dto.SignupRequest( + "user1", + "Password1!", + "홍길동", + "test@test.com", + "1997-01-01" + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().memberId()).isEqualTo("user1") + ); + } + + @DisplayName("μ€‘λ³΅λœ ID둜 νšŒμ›κ°€μž…ν•˜λ©΄ 409 CONFLICT 응닡을 λ°›λŠ”λ‹€.") + @Test + void signup_fail_duplicate_id() { + // arrange - λ¨Όμ € νšŒμ›κ°€μž… + MemberV1Dto.SignupRequest firstRequest = new MemberV1Dto.SignupRequest( + "user1", + "Password1!", + "홍길동", + "test@test.com", + "1997-01-01" + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(firstRequest, headers); + + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, httpEntity, new ParameterizedTypeReference>() {}); + + // 같은 ID둜 λ‹€μ‹œ νšŒμ›κ°€μž… μ‹œλ„ + MemberV1Dto.SignupRequest duplicateRequest = new MemberV1Dto.SignupRequest( + "user1", + "Password2!", + "κΉ€μ² μˆ˜", + "test2@test.com", + "1998-02-02" + ); + + HttpEntity duplicateHttpEntity = new HttpEntity<>(duplicateRequest, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, duplicateHttpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("잘λͺ»λœ 이메일 ν˜•μ‹μœΌλ‘œ νšŒμ›κ°€μž…ν•˜λ©΄ 400 BAD_REQUEST 응닡을 λ°›λŠ”λ‹€.") + @Test + void signup_fail_invalid_email() { + // arrange + MemberV1Dto.SignupRequest request = new MemberV1Dto.SignupRequest( + "user1", + "Password1!", + "홍길동", + "invalid-email", + "1997-01-01" + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ 정책을 μœ„λ°˜ν•˜λ©΄ 400 BAD_REQUEST 응닡을 λ°›λŠ”λ‹€.") + @Test + void signup_fail_invalid_password() { + // arrange - 특수문자 μ—†λŠ” λΉ„λ°€λ²ˆν˜Έ + MemberV1Dto.SignupRequest request = new MemberV1Dto.SignupRequest( + "user1", + "Password1", + "홍길동", + "test@test.com", + "1997-01-01" + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("GET /api/v1/members/me") + @Nested + class Me { + + @DisplayName("인증된 μ‚¬μš©μžκ°€ λ‚΄ 정보λ₯Ό μ‘°νšŒν•˜λ©΄ λ§ˆμŠ€ν‚Ήλœ μ΄λ¦„μœΌλ‘œ μ‘λ‹΅λ°›λŠ”λ‹€.") + @Test + void me_success_with_masked_name() { + // arrange + String rawPassword = "Password1!"; + String encodedPassword = passwordEncoder.encode(rawPassword); + Member member = new Member( + new MemberId("testuser"), + Password.ofEncoded(encodedPassword), + new Name("홍길동"), + new Email("test@test.com"), + new BirthDate("1997-01-01") + ); + memberRepository.save(member); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", rawPassword); + HttpEntity httpEntity = new HttpEntity<>(headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ME_ENDPOINT, HttpMethod.GET, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().memberId()).isEqualTo("testuser"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@test.com"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo("1997-01-01") + ); + } + + @DisplayName("ν•œ κΈ€μž 이름인 경우 전체가 λ§ˆμŠ€ν‚Ήλœλ‹€.") + @Test + void me_success_with_single_char_name() { + // arrange + String rawPassword = "Password1!"; + String encodedPassword = passwordEncoder.encode(rawPassword); + Member member = new Member( + new MemberId("testuser2"), + Password.ofEncoded(encodedPassword), + new Name("홍"), + new Email("test2@test.com"), + new BirthDate("1997-01-01") + ); + memberRepository.save(member); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser2"); + headers.set("X-Loopers-LoginPw", rawPassword); + HttpEntity httpEntity = new HttpEntity<>(headers); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ME_ENDPOINT, HttpMethod.GET, httpEntity, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("*") + ); + } + } + + @DisplayName("PUT /api/v1/members/me/password") + @Nested + class ChangePassword { + + private Member createMember(String memberId, String rawPassword) { + String encodedPassword = passwordEncoder.encode(rawPassword); + Member member = new Member( + new MemberId(memberId), + Password.ofEncoded(encodedPassword), + new Name("홍길동"), + new Email(memberId + "@test.com"), + new BirthDate("1997-01-01") + ); + return memberRepository.save(member); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜λ©΄ λΉ„λ°€λ²ˆν˜Έ 변경에 μ„±κ³΅ν•œλ‹€.") + @Test + void change_password_success() { + // arrange + String currentPassword = "Password1!"; + String newPassword = "NewPass456!"; + createMember("testuser", currentPassword); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", currentPassword); + + MemberV1Dto.ChangePasswordRequest request = + new MemberV1Dto.ChangePasswordRequest(currentPassword, newPassword); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // act + ResponseEntity> response = testRestTemplate.exchange( + CHANGE_PASSWORD_ENDPOINT, HttpMethod.PUT, httpEntity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // λ³€κ²½λœ λΉ„λ°€λ²ˆν˜Έλ‘œ /me API ν˜ΈμΆœν•˜μ—¬ μ‹€μ œ 둜그인 검증 + HttpHeaders newHeaders = new HttpHeaders(); + newHeaders.set("X-Loopers-LoginId", "testuser"); + newHeaders.set("X-Loopers-LoginPw", newPassword); + HttpEntity meEntity = new HttpEntity<>(newHeaders); + + ResponseEntity> meResponse = testRestTemplate.exchange( + ME_ENDPOINT, HttpMethod.GET, meEntity, + new ParameterizedTypeReference<>() {} + ); + + assertThat(meResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(meResponse.getBody().data().memberId()).isEqualTo("testuser"); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ 틀리면 400 BAD_REQUEST 응닡을 λ°›λŠ”λ‹€.") + @Test + void change_password_fail_wrong_current() { + // arrange + String currentPassword = "Password1!"; + createMember("testuser", currentPassword); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", currentPassword); + + MemberV1Dto.ChangePasswordRequest request = + new MemberV1Dto.ChangePasswordRequest("WrongPass1!", "NewPass456!"); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // act + ResponseEntity> response = testRestTemplate.exchange( + CHANGE_PASSWORD_ENDPOINT, HttpMethod.PUT, httpEntity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ κΈ°μ‘΄κ³Ό λ™μΌν•˜λ©΄ 400 BAD_REQUEST 응닡을 λ°›λŠ”λ‹€.") + @Test + void change_password_fail_same_password() { + // arrange + String currentPassword = "Password1!"; + createMember("testuser", currentPassword); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", currentPassword); + + MemberV1Dto.ChangePasswordRequest request = + new MemberV1Dto.ChangePasswordRequest(currentPassword, currentPassword); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // act + ResponseEntity> response = testRestTemplate.exchange( + CHANGE_PASSWORD_ENDPOINT, HttpMethod.PUT, httpEntity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("인증 없이 λΉ„λ°€λ²ˆν˜Έ 변경을 μ‹œλ„ν•˜λ©΄ 401 UNAUTHORIZED 응닡을 λ°›λŠ”λ‹€.") + @Test + void change_password_fail_unauthorized() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + MemberV1Dto.ChangePasswordRequest request = + new MemberV1Dto.ChangePasswordRequest("Password1!", "NewPass456!"); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // act + ResponseEntity> response = testRestTemplate.exchange( + CHANGE_PASSWORD_ENDPOINT, HttpMethod.PUT, httpEntity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/filter/AuthenticationFilterTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/filter/AuthenticationFilterTest.java new file mode 100644 index 00000000..26943188 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/filter/AuthenticationFilterTest.java @@ -0,0 +1,185 @@ +package com.loopers.interfaces.filter; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.PasswordEncoder; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.MemberId; +import com.loopers.domain.member.vo.Name; +import com.loopers.domain.member.vo.Password; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import 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.ResponseEntity; +import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AuthenticationFilterTest { + + private static final String ME_ENDPOINT = "/api/v1/members/me"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Member testMember; + + @BeforeEach + void setUp() { + String encodedPassword = passwordEncoder.encode("Password1!"); + testMember = new Member( + new MemberId("testuser"), + Password.ofEncoded(encodedPassword), + new Name("홍길동"), + new Email("test@test.com"), + new BirthDate("1997-01-01") + ); + memberRepository.save(testMember); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("인증 ν•„ν„° ν…ŒμŠ€νŠΈ") + @Nested + class AuthenticationTest { + + @DisplayName("인증 헀더 없이 보호된 API에 μ ‘κ·Όν•˜λ©΄ 401 UNAUTHORIZED 응닡을 λ°›λŠ”λ‹€.") + @Test + void access_protected_api_without_auth_header_returns_unauthorized() { + // act + ResponseEntity> response = testRestTemplate.exchange( + ME_ENDPOINT, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("잘λͺ»λœ λΉ„λ°€λ²ˆν˜Έλ‘œ μ ‘κ·Όν•˜λ©΄ 401 UNAUTHORIZED 응닡을 λ°›λŠ”λ‹€.") + @Test + void access_protected_api_with_wrong_password_returns_unauthorized() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPassword1!"); + HttpEntity httpEntity = new HttpEntity<>(headers); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ME_ENDPOINT, + HttpMethod.GET, + httpEntity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ‚¬μš©μžλ‘œ μ ‘κ·Όν•˜λ©΄ 401 UNAUTHORIZED 응닡을 λ°›λŠ”λ‹€.") + @Test + void access_protected_api_with_non_existent_user_returns_unauthorized() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nonexistent"); + headers.set("X-Loopers-LoginPw", "Password1!"); + HttpEntity httpEntity = new HttpEntity<>(headers); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ME_ENDPOINT, + HttpMethod.GET, + httpEntity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("μœ νš¨ν•œ 인증 μ •λ³΄λ‘œ 보호된 API에 μ ‘κ·Όν•˜λ©΄ μ„±κ³΅ν•œλ‹€.") + @Test + void access_protected_api_with_valid_auth_returns_success() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Password1!"); + HttpEntity httpEntity = new HttpEntity<>(headers); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ME_ENDPOINT, + HttpMethod.GET, + httpEntity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("public κ²½λ‘œμ™€ μ ‘λ‘μ‚¬λ§Œ 같은 κ²½λ‘œλŠ” 인증이 ν•„μš”ν•˜λ‹€. (예: /api/v1/members/signup-admin)") + @Test + void path_with_same_prefix_as_public_path_requires_auth() { + // arrange - /signup은 publicμ΄μ§€λ§Œ /signup-admin은 public이 μ•„λ‹˜ + String signupAdminPath = "/api/v1/members/signup-admin"; + + // act - 인증 없이 μš”μ²­ + ResponseEntity> response = testRestTemplate.exchange( + signupAdminPath, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert - 인증이 ν•„μš”ν•˜λ―€λ‘œ 401 λ°˜ν™˜ (404κ°€ μ•„λ‹Œ 401이 λ¨Όμ € λ°˜ν™˜λ¨) + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("public 경둜의 ν•˜μœ„ κ²½λ‘œλŠ” 인증 없이 μ ‘κ·Ό κ°€λŠ₯ν•˜λ‹€. (예: /api/v1/members/signup/)") + @Test + void subpath_of_public_path_is_accessible_without_auth() { + // arrange - /signup의 ν•˜μœ„ 경둜 + String signupSubPath = "/api/v1/members/signup/something"; + + // act - 인증 없이 μš”μ²­ + ResponseEntity> response = testRestTemplate.exchange( + signupSubPath, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert - public 경둜의 ν•˜μœ„ κ²½λ‘œμ΄λ―€λ‘œ 인증 톡과 (404λŠ” λΌμš°νŒ… 문제) + assertThat(response.getStatusCode()).isNotEqualTo(HttpStatus.UNAUTHORIZED); + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8..dc167f2e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ subprojects { dependencyManagement { imports { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") + mavenBom("org.testcontainers:testcontainers-bom:${project.properties["testcontainersVersion"]}") } } diff --git a/gradle.properties b/gradle.properties index 142d7120..5ae37ac9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ springBootVersion=3.4.4 springDependencyManagementVersion=1.1.7 springCloudDependenciesVersion=2024.0.1 ### Library versions ### +testcontainersVersion=2.0.2 springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0