From c0dcfd8aa69e2c7e316fd19b2c35527541e59906 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Mon, 2 Feb 2026 01:26:59 +0900 Subject: [PATCH 01/27] remove: deprecated codeguide --- .codeguide/loopers-1-week.md | 45 ------------------------------------ 1 file changed, 45 deletions(-) delete mode 100644 .codeguide/loopers-1-week.md 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` 응닡을 λ°˜ν™˜ν•œλ‹€. From 158e427b7d38a3dbcbf2461777db8b72621a56a1 Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 01:27:01 +0900 Subject: [PATCH 02/27] =?UTF-8?q?fix=20:=20=EC=98=88=EC=A0=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84=ED=95=9C=20testcontaine?= =?UTF-8?q?rs=20=EB=B2=84=EC=A0=84=20=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + gradle.properties | 1 + 2 files changed, 2 insertions(+) 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 From 071c2e77452d6c747a0e804dc3262ffb68469a48 Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 01:28:36 +0900 Subject: [PATCH 03/27] feat : ADD CLAUDE.md --- CLAUDE.md | 196 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 CLAUDE.md 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 From 43df9a14bb3ef7afeb05fd573e137c3d057be468 Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 22:11:22 +0900 Subject: [PATCH 04/27] =?UTF-8?q?test=20:=20Password=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/vo/PasswordTest.java | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java 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..32e96eeb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java @@ -0,0 +1,66 @@ +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; + +public class PasswordTest { + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ μ •μ±…(8~16자, 영문/숫자/특수문자 포함)을 μ€€μˆ˜ν•˜λ©΄ 생성에 μ„±κ³΅ν•œλ‹€.") + @Test + void create_success() { + // given + String pw = "PassWord123!"; + String birth = "1997-01-01"; + + // when + Password password = new Password(pw, birth); + + // then + assertThat(password).isNotNull(); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ 길이가 8자 λ―Έλ§Œμ΄κ±°λ‚˜, 16자 초과면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"Short1!", "TooooooooooooLongPassword123!"}) + void create_fail_length(String invalidPw) { + assertThatThrownBy(() -> new Password(invalidPw, "1997-01-01")) + .isInstanceOf(CoreException.class) + .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); + + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— 생년월일이 ν¬ν•¨λ˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void create_fail_contains_birth() { + // given + String birth = "19970101"; + String invalidPw = "Test970101!"; + + // when & then + assertThatThrownBy(() -> new Password(invalidPw, birth)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("생년월일"); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— 생년월일 νŒ¨ν„΄(YYYY, YY, MMDD)이 ν¬ν•¨λ˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @CsvSource({ + "1997-12-31, Pass1997!@#", // YYYY 포함 + "1997-12-31, Pass97!@#", // YY 포함 + "1997-12-31, Pass1231!@#", // MMDD 포함 + "1997-12-31, Pass971231!@#" // YYMMDD 포함 + }) + void create_fail_contains_birth_pattern(String birth, String invalidPw) { + assertThatThrownBy(() -> new Password(invalidPw, birth)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("생년월일"); + } +} From 0e7fe6644cae2284c27dccfcd7d9ce58d5d9ccfb Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 22:11:39 +0900 Subject: [PATCH 05/27] =?UTF-8?q?feat=20:=20Password=20VO=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/member/vo/Password.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java 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..61cc5f2a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java @@ -0,0 +1,61 @@ +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.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Password { + private String value; + + public Password(String pw, String birth) { + validate(pw, birth); + this.value = pw; + } + + private void validate(String pw, String birth) { + if(pw == null || pw.length() < 8 || pw.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” 8~16μžμ—¬μ•Ό ν•©λ‹ˆλ‹€."); + } + + String regex = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).+$"; + if (!pw.matches(regex)) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” 영문, 숫자, 특수문자λ₯Ό 포함해야 ν•©λ‹ˆλ‹€."); + } + + checkBirthPatterns(pw, birth); + } + + private void checkBirthPatterns(String rawPassword, String birth) { + String cleanBirth = birth.replaceAll("-", ""); + if (cleanBirth.length() != 8) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + String year = cleanBirth.substring(0, 4); + String month = cleanBirth.substring(4, 6); + String day = cleanBirth.substring(6, 8); + + String yy = year.substring(2); + String mmdd = month + day; + + String[] blackListPatterns = { + year, + yy, + mmdd, + yy + mmdd, + year + mmdd + }; + + for (String pattern : blackListPatterns) { + if (rawPassword.contains(pattern)) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜Έμ— 생년월일과 κ΄€λ ¨λœ 숫자(" + pattern + ")λ₯Ό 포함할 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + } + } +} From 67339fbf16efa90211c19028ba155d5a2ff69a62 Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 23:21:26 +0900 Subject: [PATCH 06/27] =?UTF-8?q?chore=20:=20=EC=95=94=ED=98=B8=ED=99=94?= =?UTF-8?q?=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) 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"]}") From 0abd9730627ec2cf3487a8ca19d7cdf574214dcc Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 23:32:49 +0900 Subject: [PATCH 07/27] =?UTF-8?q?feat=20:=20BirthDate=20VO=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/member/vo/BirthDate.java | 45 +++++++++++ .../domain/member/vo/BirthDateTest.java | 75 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java 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..e16d6786 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java @@ -0,0 +1,45 @@ +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.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BirthDate { + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd") + .withResolverStyle(ResolverStyle.STRICT); + + private String value; + + public BirthDate(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + + try { + LocalDate.parse(value, FORMATTER); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. (yyyy-MM-dd)"); + } + } + + public String toPlainString() { + return value.replaceAll("-", ""); + } +} 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..1127daa6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java @@ -0,0 +1,75 @@ +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 BirthDateTest { + + @DisplayName("yyyy-MM-dd ν˜•μ‹μ˜ 생년월일은 생성에 μ„±κ³΅ν•œλ‹€.") + @Test + void create_success() { + // given + String birth = "1997-01-15"; + + // when + BirthDate birthDate = new BirthDate(birth); + + // then + assertThat(birthDate.getValue()).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"); + } +} From f96b07f6ea7749e5a3308302e351e45b9b644ef3 Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 23:33:13 +0900 Subject: [PATCH 08/27] =?UTF-8?q?feat=20:=20MemberId=20VO=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/member/vo/MemberId.java | 38 +++++++++++ .../domain/member/vo/MemberIdTest.java | 63 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java 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..81073ddd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.java @@ -0,0 +1,38 @@ +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.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberId { + + 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() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "νšŒμ› IDλŠ” " + MAX_LENGTH + "자 이내여야 ν•©λ‹ˆλ‹€."); + } + + if (!value.matches(PATTERN)) { + throw new CoreException(ErrorType.BAD_REQUEST, "νšŒμ› IDλŠ” 영문과 숫자만 μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€."); + } + } +} 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); + } +} From 0bb46d66fb1fc3a243eae464d61e2f72f782103f Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 23:33:35 +0900 Subject: [PATCH 09/27] =?UTF-8?q?feat=20:=20Name=20VO=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/member/vo/Name.java | 34 ++++++++++ .../loopers/domain/member/vo/NameTest.java | 62 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/vo/NameTest.java 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..6ccc5213 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java @@ -0,0 +1,34 @@ +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.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Name { + + private String value; + + public Name(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + } + + public String masked() { + if (value.length() == 1) { + return "*"; + } + return value.substring(0, value.length() - 1) + "*"; + } +} 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); + } +} From 7c71610156fbcc4b7afbd1301d52f6a66af6901a Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 23:34:07 +0900 Subject: [PATCH 10/27] =?UTF-8?q?feat=20:=20Email=20VO=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/member/vo/Email.java | 37 ++++++++++++++ .../loopers/domain/member/vo/EmailTest.java | 51 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java 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..5285ad70 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java @@ -0,0 +1,37 @@ +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.Getter; +import lombok.NoArgsConstructor; + +import java.util.regex.Pattern; + +@Getter +@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) { + validate(value); + this.value = value; + } + + 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/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); + } +} From bb6902f80911e08aea86f76583adc539eff8ec18 Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 23:48:12 +0900 Subject: [PATCH 11/27] =?UTF-8?q?refactor=20:=20Password=20VO=EA=B0=80=20B?= =?UTF-8?q?irthDate=20VO=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/member/vo/Password.java | 51 +++++++++++------- .../domain/member/vo/PasswordTest.java | 54 +++++++++++++------ 2 files changed, 68 insertions(+), 37 deletions(-) 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 index 61cc5f2a..ad989a13 100644 --- 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 @@ -1,5 +1,6 @@ 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; @@ -11,38 +12,44 @@ @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; - public Password(String pw, String birth) { - validate(pw, birth); - this.value = pw; + private Password(String encodedValue) { + this.value = encodedValue; + } + + public static Password of(String rawPassword, BirthDate birthDate) { + validate(rawPassword, birthDate); + return new Password(rawPassword); + } + + public static Password ofEncoded(String encodedValue) { + return new Password(encodedValue); } - private void validate(String pw, String birth) { - if(pw == null || pw.length() < 8 || pw.length() > 16) { - throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” 8~16μžμ—¬μ•Ό ν•©λ‹ˆλ‹€."); + private static void validate(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 + "μžμ—¬μ•Ό ν•©λ‹ˆλ‹€."); } - String regex = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).+$"; - if (!pw.matches(regex)) { + if (!rawPassword.matches(COMPLEXITY_REGEX)) { throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” 영문, 숫자, 특수문자λ₯Ό 포함해야 ν•©λ‹ˆλ‹€."); } - checkBirthPatterns(pw, birth); + checkBirthPatterns(rawPassword, birthDate); } - private void checkBirthPatterns(String rawPassword, String birth) { - String cleanBirth = birth.replaceAll("-", ""); - if (cleanBirth.length() != 8) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); - } - - String year = cleanBirth.substring(0, 4); - String month = cleanBirth.substring(4, 6); - String day = cleanBirth.substring(6, 8); + private static void checkBirthPatterns(String rawPassword, BirthDate birthDate) { + String plainBirth = birthDate.toPlainString(); - String yy = year.substring(2); - String mmdd = month + day; + String year = plainBirth.substring(0, 4); + String yy = plainBirth.substring(2, 4); + String mmdd = plainBirth.substring(4, 8); String[] blackListPatterns = { year, @@ -58,4 +65,8 @@ private void checkBirthPatterns(String rawPassword, String birth) { } } } + + public boolean matches(String rawPassword, PasswordEncoder encoder) { + return encoder.matches(rawPassword, this.value); + } } 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 index 32e96eeb..ddb77efc 100644 --- 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 @@ -11,17 +11,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -public class PasswordTest { +class PasswordTest { @DisplayName("λΉ„λ°€λ²ˆν˜Έ μ •μ±…(8~16자, 영문/숫자/특수문자 포함)을 μ€€μˆ˜ν•˜λ©΄ 생성에 μ„±κ³΅ν•œλ‹€.") @Test void create_success() { // given String pw = "PassWord123!"; - String birth = "1997-01-01"; + BirthDate birthDate = new BirthDate("1997-01-01"); // when - Password password = new Password(pw, birth); + Password password = Password.of(pw, birthDate); // then assertThat(password).isNotNull(); @@ -31,36 +31,56 @@ void create_success() { @ParameterizedTest @ValueSource(strings = {"Short1!", "TooooooooooooLongPassword123!"}) void create_fail_length(String invalidPw) { - assertThatThrownBy(() -> new Password(invalidPw, "1997-01-01")) + // given + BirthDate birthDate = new BirthDate("1997-01-01"); + + // when & then + assertThatThrownBy(() -> Password.of(invalidPw, birthDate)) .isInstanceOf(CoreException.class) .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); - } - @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— 생년월일이 ν¬ν•¨λ˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") - @Test - void create_fail_contains_birth() { + @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— 영문, 숫자, νŠΉμˆ˜λ¬Έμžκ°€ λͺ¨λ‘ ν¬ν•¨λ˜μ§€ μ•ŠμœΌλ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ParameterizedTest + @ValueSource(strings = {"Password123", "Password!@#", "12345678!@#"}) + void create_fail_complexity(String invalidPw) { // given - String birth = "19970101"; - String invalidPw = "Test970101!"; + BirthDate birthDate = new BirthDate("1997-01-01"); // when & then - assertThatThrownBy(() -> new Password(invalidPw, birth)) + assertThatThrownBy(() -> Password.of(invalidPw, birthDate)) .isInstanceOf(CoreException.class) - .hasMessageContaining("생년월일"); + .hasMessageContaining("영문, 숫자, 특수문자"); } @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— 생년월일 νŒ¨ν„΄(YYYY, YY, MMDD)이 ν¬ν•¨λ˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") @ParameterizedTest @CsvSource({ - "1997-12-31, Pass1997!@#", // YYYY 포함 - "1997-12-31, Pass97!@#", // YY 포함 - "1997-12-31, Pass1231!@#", // MMDD 포함 - "1997-12-31, Pass971231!@#" // YYMMDD 포함 + "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) { - assertThatThrownBy(() -> new Password(invalidPw, birth)) + // given + BirthDate birthDate = new BirthDate(birth); + + // when & then + assertThatThrownBy(() -> Password.of(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); + } } From ec59da8af8feb60836be6d060d879f745649b0fb Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 23:49:12 +0900 Subject: [PATCH 12/27] =?UTF-8?q?feat=20:=20PasswordEncoder=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20BCrypt=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EC=B2=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/PasswordEncoder.java | 8 +++++++ .../BCryptPasswordEncoderAdapter.java | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/security/BCryptPasswordEncoderAdapter.java 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/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); + } +} From 46a63e9b2ed9dd5f0ca87536ded8abaf2c8719fd Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 23:49:34 +0900 Subject: [PATCH 13/27] =?UTF-8?q?feat=20:=20Member=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/member/Member.java | 72 +++++++++++++++++++ .../domain/member/MemberRepository.java | 6 ++ .../com/loopers/domain/member/MemberTest.java | 31 ++++++++ 3 files changed, 109 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java 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..1b85c0cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,72 @@ +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 com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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) { + if (!this.password.matches(currentPassword, encoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, "ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + Password newPw = Password.of(newPassword, this.birthDate); + String encodedNewPassword = encoder.encode(newPassword); + + if (this.password.matches(newPassword, encoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, "κΈ°μ‘΄ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•œ λΉ„λ°€λ²ˆν˜ΈλŠ” μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + this.password = Password.ofEncoded(encodedNewPassword); + } + + public String getMaskedName() { + return this.name.masked(); + } +} 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..09c4ad6e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.member; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { +} 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..3325b634 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,31 @@ +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 org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MemberTest { + + @DisplayName("νšŒμ› 생성 μ‹œ 각 ν•„λ“œμ˜ 검증 λ‘œμ§μ€ VOμ—κ²Œ μœ„μž„ν•œλ‹€.") + @Test + void create_member_success() { + // given + MemberId memberId = new MemberId("user1"); + BirthDate birthDate = new BirthDate("1997-01-01"); + Password password = Password.of("Valid123!", birthDate); + 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(); + } +} From f992c114031c4abff2bf8bb8bb29ba935b68d4a5 Mon Sep 17 00:00:00 2001 From: madirony Date: Thu, 5 Feb 2026 00:21:09 +0900 Subject: [PATCH 14/27] =?UTF-8?q?refactor=20:=20MemberRepository=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=EC=9D=84=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=EC=97=90=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/MemberRepository.java | 10 +++++-- .../member/MemberJpaRepository.java | 17 +++++++++++ .../member/MemberRepositoryImpl.java | 30 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java 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 index 09c4ad6e..1550e6e3 100644 --- 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 @@ -1,6 +1,12 @@ package com.loopers.domain.member; -import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; -public interface MemberRepository extends JpaRepository { +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/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..ed5207a9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +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) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByMemberIdValue(String memberIdValue) { + return memberJpaRepository.findByMemberIdValue(memberIdValue); + } + + @Override + public boolean existsByMemberIdValue(String memberIdValue) { + return memberJpaRepository.existsByMemberIdValue(memberIdValue); + } +} From a1e8854de5ec79bd740118d8263982c03d76d13b Mon Sep 17 00:00:00 2001 From: madirony Date: Thu, 5 Feb 2026 00:21:19 +0900 Subject: [PATCH 15/27] =?UTF-8?q?feat=20:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/member/MemberService.java | 51 +++++++++ .../domain/member/MemberServiceTest.java | 103 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java 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..3aee3f69 --- /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; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + 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 password = Password.of(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 record SignupCommand( + String memberId, + String password, + String name, + String email, + String birthDate + ) {} +} 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); + } +} From c321acc0f05dbbb470c535080344bddf72ac3049 Mon Sep 17 00:00:00 2001 From: madirony Date: Thu, 5 Feb 2026 00:21:26 +0900 Subject: [PATCH 16/27] =?UTF-8?q?feat=20:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/member/MemberV1Controller.java | 24 +++ .../interfaces/api/member/MemberV1Dto.java | 39 ++++ .../interfaces/api/MemberV1ApiE2ETest.java | 179 ++++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java 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..bb576a58 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/members") +@RequiredArgsConstructor +public class MemberV1Controller { + + private final MemberService memberService; + + @PostMapping("/signup") + public ApiResponse signup(@RequestBody MemberV1Dto.SignupRequest request) { + Member member = memberService.signup(request.toCommand()); + return ApiResponse.success(MemberV1Dto.SignupResponse.from(member)); + } +} 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..0cd09b27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,39 @@ +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() + ); + } + } +} 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..891a7242 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -0,0 +1,179 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.member.MemberRepository; +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 final TestRestTemplate testRestTemplate; + private final MemberRepository memberRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberV1ApiE2ETest( + TestRestTemplate testRestTemplate, + MemberRepository memberRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.memberRepository = memberRepository; + 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) + ); + } + } +} From c5483f8cb1e5778beaf20f154e8b77c146674018 Mon Sep 17 00:00:00 2001 From: madirony Date: Thu, 5 Feb 2026 12:17:08 +0900 Subject: [PATCH 17/27] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthenticationFilter μΆ”κ°€ (X-Loopers-LoginId, X-Loopers-LoginPw 헀더 인증) - GET /api/v1/members/me μ—”λ“œν¬μΈνŠΈ κ΅¬ν˜„ - 이름 λ§ˆμŠ€ν‚Ή 적용 (λ§ˆμ§€λ§‰ κΈ€μžλ₯Ό *둜 λ³€κ²½) Co-Authored-By: Claude Opus 4.5 --- .../api/member/MemberV1Controller.java | 8 + .../interfaces/api/member/MemberV1Dto.java | 16 ++ .../filter/AuthenticationFilter.java | 98 ++++++++++++ .../interfaces/api/MemberV1ApiE2ETest.java | 84 ++++++++++ .../filter/AuthenticationFilterTest.java | 151 ++++++++++++++++++ 5 files changed, 357 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/filter/AuthenticationFilter.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/filter/AuthenticationFilterTest.java 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 index bb576a58..9d6f2e3b 100644 --- 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 @@ -3,7 +3,9 @@ import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberService; import com.loopers.interfaces.api.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -21,4 +23,10 @@ public ApiResponse signup(@RequestBody MemberV1Dto.S Member member = memberService.signup(request.toCommand()); return ApiResponse.success(MemberV1Dto.SignupResponse.from(member)); } + + @GetMapping("/me") + public ApiResponse me(HttpServletRequest request) { + Member member = (Member) request.getAttribute("authenticatedMember"); + return ApiResponse.success(MemberV1Dto.MeResponse.from(member)); + } } 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 index 0cd09b27..301f2c01 100644 --- 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 @@ -36,4 +36,20 @@ public static SignupResponse from(Member member) { ); } } + + 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().getValue() + ); + } + } } 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..d60d4da8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/filter/AuthenticationFilter.java @@ -0,0 +1,98 @@ +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(path::startsWith); + } + + 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/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java index 891a7242..2c1e1a26 100644 --- 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 @@ -1,6 +1,13 @@ 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; @@ -25,19 +32,23 @@ class MemberV1ApiE2ETest { private static final String SIGNUP_ENDPOINT = "/api/v1/members/signup"; + private static final String ME_ENDPOINT = "/api/v1/members/me"; 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; } @@ -176,4 +187,77 @@ void signup_fail_invalid_password() { ); } } + + @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("*") + ); + } + } } 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..bd7d2f3c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/filter/AuthenticationFilterTest.java @@ -0,0 +1,151 @@ +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); + } + } +} From 556f03afceb0056fe43b90c12fb0e9f6bacee515 Mon Sep 17 00:00:00 2001 From: madirony Date: Thu, 5 Feb 2026 12:21:06 +0900 Subject: [PATCH 18/27] =?UTF-8?q?refactor:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EB=A1=9C=EC=A7=81=EC=9D=84=20?= =?UTF-8?q?Password=20VO=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Password.change() λ©”μ„œλ“œ μΆ”κ°€ (ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 확인, μƒˆ λΉ„λ°€λ²ˆν˜Έ 검증, 동일 λΉ„λ°€λ²ˆν˜Έ λ°©μ§€) - Member.updatePassword()λ₯Ό Password.change() μœ„μž„μœΌλ‘œ λ‹¨μˆœν™” - μ‚¬μš©λ˜μ§€ μ•ŠλŠ” Member.getMaskedName() 제거 Co-Authored-By: Claude Opus 4.5 --- .../com/loopers/domain/member/Member.java | 19 +---- .../loopers/domain/member/vo/Password.java | 15 ++++ .../domain/member/vo/PasswordTest.java | 81 +++++++++++++++++++ 3 files changed, 97 insertions(+), 18 deletions(-) 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 index 1b85c0cf..8d6c41d7 100644 --- 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 @@ -6,8 +6,6 @@ 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 jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; import jakarta.persistence.Embedded; @@ -52,21 +50,6 @@ public Member(MemberId memberId, Password password, Name name, Email email, Birt } public void updatePassword(String currentPassword, String newPassword, PasswordEncoder encoder) { - if (!this.password.matches(currentPassword, encoder)) { - throw new CoreException(ErrorType.BAD_REQUEST, "ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); - } - - Password newPw = Password.of(newPassword, this.birthDate); - String encodedNewPassword = encoder.encode(newPassword); - - if (this.password.matches(newPassword, encoder)) { - throw new CoreException(ErrorType.BAD_REQUEST, "κΈ°μ‘΄ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•œ λΉ„λ°€λ²ˆν˜ΈλŠ” μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€."); - } - - this.password = Password.ofEncoded(encodedNewPassword); - } - - public String getMaskedName() { - return this.name.masked(); + this.password = this.password.change(currentPassword, newPassword, this.birthDate, encoder); } } 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 index ad989a13..e7cb05f3 100644 --- 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 @@ -69,4 +69,19 @@ private static void checkBirthPatterns(String rawPassword, BirthDate birthDate) 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.of(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/test/java/com/loopers/domain/member/vo/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java index ddb77efc..90c0b97f 100644 --- 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 @@ -1,8 +1,10 @@ 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; @@ -83,4 +85,83 @@ void create_with_encoded_value() { // 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자"); + } + } } From d70e9adae1babf5ffaa94699d4a91177342e0ae6 Mon Sep 17 00:00:00 2001 From: madirony Date: Thu, 5 Feb 2026 12:23:50 +0900 Subject: [PATCH 19/27] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=88=98=EC=A0=95=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PUT /api/v1/members/me/password μ—”λ“œν¬μΈνŠΈ μΆ”κ°€ - MemberService.changePassword() λ©”μ„œλ“œ μΆ”κ°€ - ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 확인, 동일 λΉ„λ°€λ²ˆν˜Έ λ°©μ§€, μ •μ±… 검증 포함 Co-Authored-By: Claude Opus 4.5 --- .../loopers/domain/member/MemberService.java | 5 + .../api/member/MemberV1Controller.java | 11 ++ .../interfaces/api/member/MemberV1Dto.java | 5 + .../interfaces/api/MemberV1ApiE2ETest.java | 118 ++++++++++++++++++ 4 files changed, 139 insertions(+) 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 index 3aee3f69..801a5bfe 100644 --- 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 @@ -41,6 +41,11 @@ public Member signup(SignupCommand command) { return memberRepository.save(member); } + @Transactional + public void changePassword(Member member, String currentPassword, String newPassword) { + member.updatePassword(currentPassword, newPassword, passwordEncoder); + } + public record SignupCommand( String memberId, String password, 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 index 9d6f2e3b..366f44fb 100644 --- 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 @@ -7,6 +7,7 @@ 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; @@ -29,4 +30,14 @@ public ApiResponse me(HttpServletRequest request) { Member member = (Member) request.getAttribute("authenticatedMember"); return ApiResponse.success(MemberV1Dto.MeResponse.from(member)); } + + @PutMapping("/me/password") + public ApiResponse changePassword( + HttpServletRequest request, + @RequestBody MemberV1Dto.ChangePasswordRequest body + ) { + Member member = (Member) request.getAttribute("authenticatedMember"); + memberService.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 index 301f2c01..b11eb2e5 100644 --- 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 @@ -37,6 +37,11 @@ public static SignupResponse from(Member member) { } } + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} + public record MeResponse( String memberId, String name, 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 index 2c1e1a26..6f7aadae 100644 --- 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 @@ -33,6 +33,7 @@ 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; @@ -260,4 +261,121 @@ void me_success_with_single_char_name() { ); } } + + @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); + } + + @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); + } + } } From 008db3c21d089237262ca41b02b26b68b06350a8 Mon Sep 17 00:00:00 2001 From: madirony Date: Thu, 5 Feb 2026 21:37:46 +0900 Subject: [PATCH 20/27] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(Servlet=20API=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/member/MemberV1Controller.java | 8 ++---- .../loopers/interfaces/config/WebConfig.java | 21 ++++++++++++++ .../interfaces/resolver/LoginUser.java | 11 ++++++++ .../resolver/LoginUserArgumentResolver.java | 28 +++++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/resolver/LoginUser.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/resolver/LoginUserArgumentResolver.java 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 index 366f44fb..0d0144f4 100644 --- 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 @@ -3,7 +3,7 @@ import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberService; import com.loopers.interfaces.api.ApiResponse; -import jakarta.servlet.http.HttpServletRequest; +import com.loopers.interfaces.resolver.LoginUser; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -26,17 +26,15 @@ public ApiResponse signup(@RequestBody MemberV1Dto.S } @GetMapping("/me") - public ApiResponse me(HttpServletRequest request) { - Member member = (Member) request.getAttribute("authenticatedMember"); + public ApiResponse me(@LoginUser Member member) { return ApiResponse.success(MemberV1Dto.MeResponse.from(member)); } @PutMapping("/me/password") public ApiResponse changePassword( - HttpServletRequest request, + @LoginUser Member member, @RequestBody MemberV1Dto.ChangePasswordRequest body ) { - Member member = (Member) request.getAttribute("authenticatedMember"); memberService.changePassword(member, body.currentPassword(), body.newPassword()); return ApiResponse.success(); } 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/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..6df3ce27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/resolver/LoginUserArgumentResolver.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.resolver; + +import com.loopers.domain.member.Member; +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(); + return request.getAttribute("authenticatedMember"); + } +} From 4066297fe6305aeb3d02b3614872299667c9802d Mon Sep 17 00:00:00 2001 From: madirony Date: Thu, 5 Feb 2026 22:22:41 +0900 Subject: [PATCH 21/27] =?UTF-8?q?refactor:=20MemberFacade=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20=EB=B0=8F=20MemberService=EC=9D=98=20=EC=88=9C?= =?UTF-8?q?=EC=88=98=20POJO=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [What] - Application Layer에 MemberFacadeλ₯Ό μΆ”κ°€ν•˜μ—¬ νŠΈλžœμž­μ…˜ 관리 μ±…μž„ μœ„μž„ - Domain Layer의 MemberServiceμ—μ„œ Spring μ˜μ‘΄μ„±(@Service, @Transactional) 제거 - DomainConfigλ₯Ό μƒμ„±ν•˜μ—¬ MemberServiceλ₯Ό μˆ˜λ™ 빈으둜 등둝 [Why] - 도메인 둜직의 μˆœμˆ˜μ„±(Purity) 확보 및 ν”„λ ˆμž„μ›Œν¬ μ˜μ‘΄μ„± 제거 - λΉ„μ¦ˆλ‹ˆμŠ€ 흐름 μ œμ–΄(Application)와 핡심 둜직(Domain)의 μ—­ν•  뢄리 --- .../application/member/MemberFacade.java | 24 +++++++++++++++++++ .../java/com/loopers/config/DomainConfig.java | 16 +++++++++++++ .../loopers/domain/member/MemberService.java | 7 +----- .../api/member/MemberV1Controller.java | 8 +++---- 4 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/DomainConfig.java 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..d71984fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,24 @@ +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 org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class MemberFacade { + + private final MemberService memberService; + + @Transactional + public Member signup(MemberService.SignupCommand command) { + return memberService.signup(command); + } + + @Transactional + public void changePassword(Member member, String currentPassword, String newPassword) { + memberService.changePassword(member, 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/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java index 801a5bfe..6311851a 100644 --- 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 @@ -8,17 +8,13 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -@Service @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; - @Transactional public Member signup(SignupCommand command) { MemberId memberId = new MemberId(command.memberId()); BirthDate birthDate = new BirthDate(command.birthDate()); @@ -27,7 +23,7 @@ public Member signup(SignupCommand command) { throw new CoreException(ErrorType.CONFLICT, "이미 μ‘΄μž¬ν•˜λŠ” νšŒμ› IDμž…λ‹ˆλ‹€."); } - Password password = Password.of(command.password(), birthDate); + Password.of(command.password(), birthDate); String encodedPassword = passwordEncoder.encode(command.password()); Member member = new Member( @@ -41,7 +37,6 @@ public Member signup(SignupCommand command) { return memberRepository.save(member); } - @Transactional public void changePassword(Member member, String currentPassword, String newPassword) { member.updatePassword(currentPassword, newPassword, passwordEncoder); } 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 index 0d0144f4..2422b2e1 100644 --- 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 @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.member; +import com.loopers.application.member.MemberFacade; import com.loopers.domain.member.Member; -import com.loopers.domain.member.MemberService; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.resolver.LoginUser; import lombok.RequiredArgsConstructor; @@ -17,11 +17,11 @@ @RequiredArgsConstructor public class MemberV1Controller { - private final MemberService memberService; + private final MemberFacade memberFacade; @PostMapping("/signup") public ApiResponse signup(@RequestBody MemberV1Dto.SignupRequest request) { - Member member = memberService.signup(request.toCommand()); + Member member = memberFacade.signup(request.toCommand()); return ApiResponse.success(MemberV1Dto.SignupResponse.from(member)); } @@ -35,7 +35,7 @@ public ApiResponse changePassword( @LoginUser Member member, @RequestBody MemberV1Dto.ChangePasswordRequest body ) { - memberService.changePassword(member, body.currentPassword(), body.newPassword()); + memberFacade.changePassword(member, body.currentPassword(), body.newPassword()); return ApiResponse.success(); } } From 4fe877e6cf509573907e5d0ba3239f8d410aa1a6 Mon Sep 17 00:00:00 2001 From: madirony Date: Thu, 5 Feb 2026 23:57:00 +0900 Subject: [PATCH 22/27] =?UTF-8?q?test:=20MemberTest=EC=97=90=20updatePassw?= =?UTF-8?q?ord=20=EC=84=B1=EA=B3=B5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/member/MemberTest.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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 index 3325b634..dc71b8cd 100644 --- 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 @@ -12,6 +12,18 @@ 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() { @@ -28,4 +40,26 @@ void create_member_success() { // then assertThat(member).isNotNull(); } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ μ‹œ Member의 password ν•„λ“œκ°€ μƒˆ λΉ„λ°€λ²ˆν˜Έλ‘œ κ΅μ²΄λœλ‹€.") + @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(); + } } From 3310a3d56c1b923f66c5b96b98232816adeaf8eb Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 01:27:01 +0900 Subject: [PATCH 23/27] =?UTF-8?q?fix=20:=20=EC=98=88=EC=A0=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84=ED=95=9C=20testcontaine?= =?UTF-8?q?rs=20=EB=B2=84=EC=A0=84=20=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + gradle.properties | 1 + 2 files changed, 2 insertions(+) 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 From 6d0393d9958f1c8d33f6d7a37a7d187b8201c8f0 Mon Sep 17 00:00:00 2001 From: madirony Date: Tue, 10 Feb 2026 12:53:06 +0900 Subject: [PATCH 24/27] =?UTF-8?q?refactor:=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EC=95=88=EC=A0=95=EC=84=B1=20=EA=B0=95=ED=99=94=20=EB=B0=8F?= =?UTF-8?q?=20CodeRabbit=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Domain: VO(Name, MemberId, Email) 검증 둜직 및 λΆˆλ³€μ„± κ°•ν™” (@EqualsAndHashCode) - Security: AuthenticationFilter 경둜 λ§€μΉ­ 취약점 보완 - Infra: LoginUserArgumentResolver μ˜ˆμ™Έ 처리 및 JPA Dirty Checking 버그 μˆ˜μ • - Test: 경계값 ν…ŒμŠ€νŠΈ 및 λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ E2E 검증 μΆ”κ°€ --- .../application/member/MemberFacade.java | 8 +- .../loopers/domain/member/vo/BirthDate.java | 33 ++++-- .../com/loopers/domain/member/vo/Email.java | 8 +- .../loopers/domain/member/vo/MemberId.java | 7 +- .../com/loopers/domain/member/vo/Name.java | 13 ++- .../loopers/domain/member/vo/Password.java | 2 + .../member/MemberRepositoryImpl.java | 10 +- .../filter/AuthenticationFilter.java | 10 +- .../resolver/LoginUserArgumentResolver.java | 10 +- .../loopers/support/error/CoreException.java | 8 +- .../com/loopers/support/error/ErrorType.java | 1 + .../com/loopers/domain/member/MemberTest.java | 105 +++++++++++++++--- .../domain/member/vo/BirthDateTest.java | 28 ++++- .../interfaces/api/MemberV1ApiE2ETest.java | 14 +++ .../filter/AuthenticationFilterTest.java | 38 ++++++- 15 files changed, 249 insertions(+), 46 deletions(-) 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 index d71984fc..c1899165 100644 --- 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 @@ -1,7 +1,10 @@ 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; @@ -11,6 +14,7 @@ public class MemberFacade { private final MemberService memberService; + private final MemberRepository memberRepository; @Transactional public Member signup(MemberService.SignupCommand command) { @@ -19,6 +23,8 @@ public Member signup(MemberService.SignupCommand command) { @Transactional public void changePassword(Member member, String currentPassword, String newPassword) { - memberService.changePassword(member, currentPassword, 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/domain/member/vo/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java index e16d6786..7b1558f5 100644 --- 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 @@ -4,15 +4,14 @@ import com.loopers.support.error.ErrorType; import jakarta.persistence.Embeddable; import lombok.AccessLevel; -import lombok.Getter; +import lombok.EqualsAndHashCode; 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 { @@ -20,26 +19,40 @@ public class BirthDate { private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd") .withResolverStyle(ResolverStyle.STRICT); - private String value; + private LocalDate value; public BirthDate(String value) { - validate(value); - this.value = value; + this.value = parse(value); + validateNotFuture(this.value); } - private void validate(String value) { + private LocalDate parse(String value) { if (value == null || value.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 ν•„μˆ˜μž…λ‹ˆλ‹€."); } try { - LocalDate.parse(value, FORMATTER); + return LocalDate.parse(value, FORMATTER); } catch (DateTimeParseException e) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. (yyyy-MM-dd)"); + 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 getValue() { + return value.format(FORMATTER); + } + + public LocalDate getDate() { + return value; + } + public String toPlainString() { - return value.replaceAll("-", ""); + return getValue().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 index 5285ad70..5cc0626d 100644 --- 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 @@ -4,12 +4,13 @@ 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 { @@ -21,8 +22,9 @@ public class Email { private String value; public Email(String value) { - validate(value); - this.value = value; + String normalized = value != null ? value.toLowerCase() : null; + validate(normalized); + this.value = normalized; } private void validate(String value) { 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 index 81073ddd..885bfb63 100644 --- 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 @@ -4,14 +4,17 @@ 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]+$"; @@ -27,8 +30,8 @@ private void validate(String value) { throw new CoreException(ErrorType.BAD_REQUEST, "νšŒμ› IDλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); } - if (value.length() > MAX_LENGTH) { - throw new CoreException(ErrorType.BAD_REQUEST, "νšŒμ› IDλŠ” " + MAX_LENGTH + "자 이내여야 ν•©λ‹ˆλ‹€."); + if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "νšŒμ› IDλŠ” " + MIN_LENGTH + "~" + MAX_LENGTH + "μžμ—¬μ•Ό ν•©λ‹ˆλ‹€."); } if (!value.matches(PATTERN)) { 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 index 6ccc5213..429ea931 100644 --- 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 @@ -4,25 +4,34 @@ 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) { - validate(value); - this.value = 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() { 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 index e7cb05f3..3403738d 100644 --- 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 @@ -5,10 +5,12 @@ 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 { 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 index ed5207a9..20c12999 100644 --- 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 @@ -2,9 +2,11 @@ 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 @@ -15,7 +17,11 @@ public class MemberRepositoryImpl implements MemberRepository { @Override public Member save(Member member) { - return memberJpaRepository.save(member); + try { + return memberJpaRepository.save(member); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 μ‘΄μž¬ν•˜λŠ” νšŒμ› μ •λ³΄μž…λ‹ˆλ‹€.", e); + } } @Override 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 index d60d4da8..c93814d7 100644 --- 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 @@ -17,7 +17,6 @@ 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; @@ -84,7 +83,14 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha } private boolean isPublicPath(String path) { - return PUBLIC_PATHS.stream().anyMatch(path::startsWith); + 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 { 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 index 6df3ce27..34e4c182 100644 --- 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 @@ -1,6 +1,8 @@ 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; @@ -23,6 +25,12 @@ public boolean supportsParameter(MethodParameter parameter) { public Object resolveArgument(@NonNull MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); - return request.getAttribute("authenticatedMember"); + 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/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java index dc71b8cd..3eb13228 100644 --- 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 @@ -5,10 +5,14 @@ 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 { @@ -41,25 +45,90 @@ void create_member_success() { assertThat(member).isNotNull(); } - @DisplayName("λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ μ‹œ Member의 password ν•„λ“œκ°€ μƒˆ λΉ„λ°€λ²ˆν˜Έλ‘œ κ΅μ²΄λœλ‹€.") - @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 - ); + @DisplayName("λΉ„λ°€λ²ˆν˜Έ λ³€κ²½") + @Nested + class UpdatePassword { - // when - member.updatePassword(currentRaw, newRaw, fakeEncoder); + @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 + ); - // then - assertThat(member.getPassword().matches(newRaw, fakeEncoder)).isTrue(); + // 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 index 1127daa6..045f2c15 100644 --- 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 @@ -6,7 +6,8 @@ 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; @@ -72,4 +73,29 @@ void 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.getValue()).isEqualTo(today); + } } 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 index 6f7aadae..10746c57 100644 --- 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 @@ -303,6 +303,20 @@ void change_password_success() { // 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 응닡을 λ°›λŠ”λ‹€.") 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 index bd7d2f3c..26943188 100644 --- 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 @@ -24,9 +24,7 @@ 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) @@ -147,5 +145,41 @@ void access_protected_api_with_valid_auth_returns_success() { // 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); + } } } From 58431ec047ddb135a325ed2f49f4e10ef1bfe68b Mon Sep 17 00:00:00 2001 From: madirony Date: Tue, 10 Feb 2026 15:01:41 +0900 Subject: [PATCH 25/27] =?UTF-8?q?refactor:=20Password.of()=20=E2=86=92=20v?= =?UTF-8?q?alidate()=EB=A1=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=9D=98?= =?UTF-8?q?=EB=8F=84=20=EB=AA=85=ED=99=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/member/MemberService.java | 2 +- .../com/loopers/domain/member/vo/Password.java | 9 ++++----- .../com/loopers/domain/member/MemberTest.java | 3 ++- .../loopers/domain/member/vo/PasswordTest.java | 17 +++++++---------- 4 files changed, 14 insertions(+), 17 deletions(-) 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 index 6311851a..87c94e73 100644 --- 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 @@ -23,7 +23,7 @@ public Member signup(SignupCommand command) { throw new CoreException(ErrorType.CONFLICT, "이미 μ‘΄μž¬ν•˜λŠ” νšŒμ› IDμž…λ‹ˆλ‹€."); } - Password.of(command.password(), birthDate); + Password.validate(command.password(), birthDate); String encodedPassword = passwordEncoder.encode(command.password()); Member member = new Member( 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 index 3403738d..cc0de132 100644 --- 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 @@ -25,16 +25,15 @@ private Password(String encodedValue) { this.value = encodedValue; } - public static Password of(String rawPassword, BirthDate birthDate) { - validate(rawPassword, birthDate); - return new Password(rawPassword); + public static void validate(String rawPassword, BirthDate birthDate) { + validatePasswordPolicy(rawPassword, birthDate); } public static Password ofEncoded(String encodedValue) { return new Password(encodedValue); } - private static void validate(String rawPassword, BirthDate birthDate) { + 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 + "μžμ—¬μ•Ό ν•©λ‹ˆλ‹€."); } @@ -78,7 +77,7 @@ public Password change(String currentPassword, String newPassword, throw new CoreException(ErrorType.BAD_REQUEST, "ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); } - Password.of(newPassword, birthDate); + Password.validate(newPassword, birthDate); if (matches(newPassword, encoder)) { throw new CoreException(ErrorType.BAD_REQUEST, "κΈ°μ‘΄ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•œ λΉ„λ°€λ²ˆν˜ΈλŠ” μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€."); 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 index 3eb13228..63de977c 100644 --- 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 @@ -34,7 +34,8 @@ void create_member_success() { // given MemberId memberId = new MemberId("user1"); BirthDate birthDate = new BirthDate("1997-01-01"); - Password password = Password.of("Valid123!", birthDate); + Password.validate("Valid123!", birthDate); + Password password = Password.ofEncoded(fakeEncoder.encode("Valid123!")); Name name = new Name("μ•€λ“œλ₯˜"); Email email = new Email("test@test.com"); 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 index 90c0b97f..1fb3b463 100644 --- 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 @@ -15,18 +15,15 @@ class PasswordTest { - @DisplayName("λΉ„λ°€λ²ˆν˜Έ μ •μ±…(8~16자, 영문/숫자/특수문자 포함)을 μ€€μˆ˜ν•˜λ©΄ 생성에 μ„±κ³΅ν•œλ‹€.") + @DisplayName("λΉ„λ°€λ²ˆν˜Έ μ •μ±…(8~16자, 영문/숫자/특수문자 포함)을 μ€€μˆ˜ν•˜λ©΄ 검증에 μ„±κ³΅ν•œλ‹€.") @Test - void create_success() { + void validate_success() { // given String pw = "PassWord123!"; BirthDate birthDate = new BirthDate("1997-01-01"); - // when - Password password = Password.of(pw, birthDate); - - // then - assertThat(password).isNotNull(); + // when & then - μ˜ˆμ™Έκ°€ λ°œμƒν•˜μ§€ μ•ŠμœΌλ©΄ 성곡 + Password.validate(pw, birthDate); } @DisplayName("λΉ„λ°€λ²ˆν˜Έ 길이가 8자 λ―Έλ§Œμ΄κ±°λ‚˜, 16자 초과면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") @@ -37,7 +34,7 @@ void create_fail_length(String invalidPw) { BirthDate birthDate = new BirthDate("1997-01-01"); // when & then - assertThatThrownBy(() -> Password.of(invalidPw, birthDate)) + assertThatThrownBy(() -> Password.validate(invalidPw, birthDate)) .isInstanceOf(CoreException.class) .extracting("errorType").isEqualTo(ErrorType.BAD_REQUEST); } @@ -50,7 +47,7 @@ void create_fail_complexity(String invalidPw) { BirthDate birthDate = new BirthDate("1997-01-01"); // when & then - assertThatThrownBy(() -> Password.of(invalidPw, birthDate)) + assertThatThrownBy(() -> Password.validate(invalidPw, birthDate)) .isInstanceOf(CoreException.class) .hasMessageContaining("영문, 숫자, 특수문자"); } @@ -68,7 +65,7 @@ void create_fail_contains_birth_pattern(String birth, String invalidPw) { BirthDate birthDate = new BirthDate(birth); // when & then - assertThatThrownBy(() -> Password.of(invalidPw, birthDate)) + assertThatThrownBy(() -> Password.validate(invalidPw, birthDate)) .isInstanceOf(CoreException.class) .hasMessageContaining("생년월일"); } From e23dab3c38afed6cb3d618a029a8e555652174d6 Mon Sep 17 00:00:00 2001 From: madirony Date: Tue, 10 Feb 2026 15:02:00 +0900 Subject: [PATCH 26/27] =?UTF-8?q?refactor:=20BirthDate.getValue()=20?= =?UTF-8?q?=E2=86=92=20getFormattedValue()=EB=A1=9C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20=EB=AA=85=ED=99=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/member/vo/BirthDate.java | 10 +++++----- .../com/loopers/interfaces/api/member/MemberV1Dto.java | 2 +- .../com/loopers/domain/member/vo/BirthDateTest.java | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) 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 index 7b1558f5..198532ed 100644 --- 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 @@ -44,15 +44,15 @@ private void validateNotFuture(LocalDate date) { } } - public String getValue() { - return value.format(FORMATTER); + public LocalDate getValue() { + return value; } - public LocalDate getDate() { - return value; + public String getFormattedValue() { + return value.format(FORMATTER); } public String toPlainString() { - return getValue().replaceAll("-", ""); + return getFormattedValue().replaceAll("-", ""); } } 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 index b11eb2e5..370a03d0 100644 --- 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 @@ -53,7 +53,7 @@ public static MeResponse from(Member member) { member.getMemberId().getValue(), member.getName().masked(), member.getEmail().getValue(), - member.getBirthDate().getValue() + member.getBirthDate().getFormattedValue() ); } } 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 index 045f2c15..82ecb0b3 100644 --- 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 @@ -23,7 +23,7 @@ void create_success() { BirthDate birthDate = new BirthDate(birth); // then - assertThat(birthDate.getValue()).isEqualTo(birth); + assertThat(birthDate.getFormattedValue()).isEqualTo(birth); } @DisplayName("잘λͺ»λœ ν˜•μ‹μ˜ 생년월일은 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") @@ -96,6 +96,6 @@ void create_success_today() { BirthDate birthDate = new BirthDate(today); // then - assertThat(birthDate.getValue()).isEqualTo(today); + assertThat(birthDate.getFormattedValue()).isEqualTo(today); } } From 39ce405fce06ace1a093dec72ac25df2ed565801 Mon Sep 17 00:00:00 2001 From: madirony Date: Tue, 10 Feb 2026 15:04:55 +0900 Subject: [PATCH 27/27] =?UTF-8?q?refactor:=20BirthDate=EC=97=90=20@Getter?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EC=88=98=EB=8F=99=20getVal?= =?UTF-8?q?ue()=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/domain/member/vo/BirthDate.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 index 198532ed..8e9e8d88 100644 --- 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 @@ -5,12 +5,14 @@ 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) @@ -44,10 +46,6 @@ private void validateNotFuture(LocalDate date) { } } - public LocalDate getValue() { - return value; - } - public String getFormattedValue() { return value.format(FORMATTER); }