From c0dcfd8aa69e2c7e316fd19b2c35527541e59906 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Mon, 2 Feb 2026 01:26:59 +0900 Subject: [PATCH 01/20] 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 78cf8595a27a07b27ba0ef2cf2d994016e5a3f62 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Tue, 3 Feb 2026 18:30:07 +0900 Subject: [PATCH 02/20] =?UTF-8?q?feat:=20claude.md=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..119e1ad7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,143 @@ +# CLAUDE.md + +## ν”„λ‘œμ νŠΈ κ°œμš” + +Spring Boot 기반 λ©€ν‹° λͺ¨λ“ˆ Java ν”„λ‘œμ νŠΈ (`loopers-java-spring-template`). +REST API, Batch, Kafka Streaming 3개의 μ‹€ν–‰ κ°€λŠ₯ν•œ μ•±κ³Ό μž¬μ‚¬μš© κ°€λŠ₯ν•œ 인프라/지원 λͺ¨λ“ˆλ‘œ κ΅¬μ„±λœλ‹€. + +## 기술 μŠ€νƒ 및 버전 + +| ꡬ성 μš”μ†Œ | 버전 | +|-----------|------| +| Java | 21 (Toolchain) | +| Spring Boot | 3.4.4 | +| Spring Cloud Dependencies | 2024.0.1 | +| Spring Dependency Management | 1.1.7 | +| Kotlin | 2.0.20 | +| springdoc-openapi | 2.7.0 | +| QueryDSL | Jakarta | +| MySQL Connector/J | Spring Boot 관리 | +| Mockito | 5.14.0 | +| spring-mockk | 4.0.2 | +| instancio-junit | 5.0.2 | + +## λͺ¨λ“ˆ ꡬ쑰 + +``` +root +β”œβ”€β”€ apps/ # μ‹€ν–‰ κ°€λŠ₯ν•œ Spring Boot μ• ν”Œλ¦¬μΌ€μ΄μ…˜ +β”‚ β”œβ”€β”€ commerce-api/ # REST API (포트 8080, 관리 포트 8081) +β”‚ β”œβ”€β”€ commerce-batch/ # Spring Batch 처리 +β”‚ └── commerce-streamer/ # Kafka 슀트리밍 처리 +β”œβ”€β”€ modules/ # μž¬μ‚¬μš© κ°€λŠ₯ν•œ 인프라 λͺ¨λ“ˆ +β”‚ β”œβ”€β”€ jpa/ # JPA/Hibernate + HikariCP μ„€μ • +β”‚ β”œβ”€β”€ redis/ # Redis Master-Replica μ„€μ • (Lettuce) +β”‚ └── kafka/ # Kafka Producer/Consumer μ„€μ • +└── supports/ # λΆ€κ°€ κΈ°λŠ₯ λͺ¨λ“ˆ + β”œβ”€β”€ jackson/ # JSON 직렬화 μ„€μ • + β”œβ”€β”€ logging/ # Logback + Slack μ•Œλ¦Ό + └── monitoring/ # Prometheus + Micrometer λ©”νŠΈλ¦­ +``` + +- `apps/` λͺ¨λ“ˆλ§Œ `bootJar` ν™œμ„±ν™”, λ‚˜λ¨Έμ§€λŠ” `jar` νƒœμŠ€ν¬λ§Œ ν™œμ„±ν™” + +## μ•„ν‚€ν…μ²˜ νŒ¨ν„΄ + +Layered Hexagonal Architecture (Ports & Adapters): + +``` +interfaces/ β†’ Controller, API Spec, DTO (HTTP 계측) +application/ β†’ Facade (μœ μŠ€μΌ€μ΄μŠ€ 쑰율) +domain/ β†’ Model, Service, Repository μΈν„°νŽ˜μ΄μŠ€ (λΉ„μ¦ˆλ‹ˆμŠ€ 둜직) +infrastructure/β†’ Repository κ΅¬ν˜„μ²΄, JPA Repository (기술 μ–΄λŒ‘ν„°) +support/ β†’ 곡톡 μ—λŸ¬, μœ ν‹Έλ¦¬ν‹° +``` + +μ˜μ‘΄μ„± λ°©ν–₯: `interfaces β†’ application β†’ domain ← infrastructure` + +## λΉŒλ“œ 및 μ‹€ν–‰ + +```bash +# λΉŒλ“œ +./gradlew build + +# commerce-api μ‹€ν–‰ +./gradlew :apps:commerce-api:bootRun + +# commerce-batch μ‹€ν–‰ (job 이름 μ§€μ • ν•„μˆ˜) +./gradlew :apps:commerce-batch:bootRun --args='--spring.batch.job.name=demoJob' + +# ν…ŒμŠ€νŠΈ +./gradlew test + +# 인프라 (MySQL, Redis, Kafka) +docker compose -f infra-compose.yml up -d + +# λͺ¨λ‹ˆν„°λ§ (Prometheus, Grafana) +docker compose -f monitoring-compose.yml up -d +``` + +## μ£Όμš” μ»¨λ²€μ…˜ + +### μ—”ν‹°ν‹° +- `BaseEntity`λ₯Ό μƒμ†ν•˜μ—¬ `id`, `created_at`, `updated_at`, `deleted_at` μžλ™ 관리 +- Soft delete νŒ¨ν„΄ (`delete()`, `restore()` λ©”μ„œλ“œ) +- Protected κΈ°λ³Έ μƒμ„±μž, `@PrePersist`/`@PreUpdate` ν›… μ‚¬μš© +- νƒ€μž„μ‘΄: UTC μ €μž₯, Asia/Seoul ν‘œμ‹œ + +### API 응닡 +```json +{ + "meta": { "result": "SUCCESS|FAIL", "errorCode": "...", "message": "..." }, + "data": { } +} +``` + +### μ—λŸ¬ 처리 +- `CoreException(ErrorType, message)` β†’ `ApiControllerAdvice`μ—μ„œ 일괄 처리 +- ErrorType: `BAD_REQUEST(400)`, `NOT_FOUND(404)`, `CONFLICT(409)`, `INTERNAL_ERROR(500)` + +### DTO λ§€ν•‘ 흐름 +``` +Entity β†’ ExampleInfo (application DTO) β†’ ExampleV1Dto (API DTO) β†’ ApiResponse +``` +- Java Record둜 λΆˆλ³€ DTO μ •μ˜, `from()` νŒ©ν† λ¦¬ λ©”μ„œλ“œ μ‚¬μš© + +### 넀이밍 +- νŒ¨ν‚€μ§€: `com.loopers.*` +- API 버전: 클래슀λͺ…에 `V1`, `V2` 접미사 (예: `ExampleV1Controller`) +- DTO: `Dto` λ˜λŠ” `Info` 접미사 +- Repository: `Repository` (μΈν„°νŽ˜μ΄μŠ€), `JpaRepository` (JPA) + +### ν…ŒμŠ€νŠΈ +- JUnit 5, `@Nested` + `@DisplayName`(ν•œκ΅­μ–΄) μ‚¬μš© +- 톡합 ν…ŒμŠ€νŠΈ: `@SpringBootTest(webEnvironment = RANDOM_PORT)` + `TestRestTemplate` +- Testcontainers: MySQL, Redis, Kafka +- `DatabaseCleanUp` ν”½μŠ€μ²˜λ‘œ ν…ŒμŠ€νŠΈ κ°„ 데이터 정리 (`@AfterEach`) +- JaCoCo μ½”λ“œ 컀버리지 (XML 리포트) +- ν”„λ‘œνŒŒμΌ: `test`, 순차 μ‹€ν–‰ (`maxParallelForks = 1`) + +### μ½”λ“œ μŠ€νƒ€μΌ +- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Slf4j` +- Jackson: `NON_NULL` 직렬화, 빈 λ¬Έμžμ—΄ β†’ null 역직렬화 +- λΉ„μ¦ˆλ‹ˆμŠ€ μ»¨ν…μŠ€νŠΈ 주석은 ν•œκ΅­μ–΄ μ‚¬μš© + +## ν™˜κ²½ ν”„λ‘œνŒŒμΌ + +`local`, `test`, `dev`, `qa`, `prd` + +| μ„€μ • | local/test | dev/qa/prd | +|------|-----------|------------| +| DDL auto | create | none | +| SQL λ‘œκΉ… | ν™œμ„±ν™” | λΉ„ν™œμ„±ν™” | +| Swagger | ν™œμ„±ν™” | prd만 λΉ„ν™œμ„±ν™” | +| Batch μŠ€ν‚€λ§ˆ | always | never | + +## ν™˜κ²½ λ³€μˆ˜ + +| λ³€μˆ˜ | μš©λ„ | +|------|------| +| `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PWD` | MySQL 접속 | +| `REDIS_MASTER_HOST`, `REDIS_MASTER_PORT` | Redis Master | +| `REDIS_REPLICA_1_HOST`, `REDIS_REPLICA_1_PORT` | Redis Replica | +| `BOOTSTRAP_SERVERS` | Kafka 브둜컀 | From 357da15880ca35330953620d2b6ec503abb8ecef Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Tue, 3 Feb 2026 18:39:12 +0900 Subject: [PATCH 03/20] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/user/UserModel.java | 80 ++++++++ .../loopers/domain/user/UserModelTest.java | 187 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java new file mode 100644 index 00000000..90700095 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -0,0 +1,80 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; + +@Entity +@Table(name = "users") +@Getter +public class UserModel extends BaseEntity { + + private String loginId; + private String password; + private String name; + private LocalDate birthday; + private String email; + + protected UserModel(){} + + public UserModel(String loginId, String password, String name, String birthday, String email){ + if(loginId == null || loginId.isBlank()){ + throw new CoreException(ErrorType.BAD_REQUEST, "둜그인 IDλŠ” λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + if(password == null || password.isBlank()){ + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + if(name == null || name.isBlank()){ + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + if(email == null || email.isBlank()){ + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + if(birthday == null || birthday.isBlank()){ + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + // 이메일 ν˜•μ‹ 검증 + if(!email.matches("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$")){ + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + // λΉ„λ°€λ²ˆν˜Έ 자리수 검증 + if(password.length() < 8 || password.length() > 16){ + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” 8자리 이상, 16자리 μ΄ν•˜μ΄μ–΄μ•Ό ν•©λ‹ˆλ‹€."); + } + + // λΉ„λ°€λ²ˆν˜Έμ— 영문 λŒ€μ†Œλ¬Έμž, 숫자, 특수문자만 ν¬ν•¨λ˜μ–΄ μžˆλŠ”μ§€ 검증 + if(!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\",./<>?]*$")){ + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜Έ ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + // λΉ„λ°€λ²ˆν˜Έμ— 생년월일이 ν¬ν•¨λ˜μ–΄ μžˆλŠ”μ§€ 검증 + String birthdayWithoutDash = birthday.replace("-", ""); + if(password.contains(birthday) || password.contains(birthdayWithoutDash)){ + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜Έμ— 생년월일을 포함할 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + try{ + this.birthday = LocalDate.parse(birthday); + }catch(DateTimeParseException e){ + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + this.loginId = loginId; + this.name = name; + this.password = password; + this.email = email; + + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java new file mode 100644 index 00000000..710fc95a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -0,0 +1,187 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UserModelTest { + + @DisplayName("νšŒμ›μ„ 생성할 λ•Œ, ") + @Nested + class Create { + + @DisplayName("λͺ¨λ“  정보가 μ˜¬λ°”λ₯΄λ©΄, μ •μƒμ μœΌλ‘œ μƒμ„±λœλ‹€.") + @Test + void createsUser_whenAllInfoIsProvided() { + // arrange + String loginId = "namjin123"; + String password = "qwer@1234"; + String name = "namjin"; + String birthDay = "1994-05-25"; + String email = "epemxksl@gmail.com"; + + // act + UserModel user = new UserModel(loginId, password, name, birthDay, email); + + // assert + assertThat(user).isNotNull(); + } + + @DisplayName("둜그인 IDκ°€ λΉ„μ–΄μžˆμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenLoginIdIsBlank() { + // arrange + String loginId = ""; + String password = "qwer@1234"; + String name = "namjin"; + String birthDay = "1994-05-25"; + String email = "epemxksl@gmail.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(loginId, password, name, birthDay, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 λΉ„μ–΄μžˆμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenNameIsBlank() { + // arrange + String loginId = "namjin123"; + String password = "qwer@1234"; + String name = ""; + String birthDay = "1994-05-25"; + String email = "epemxksl@gmail.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(loginId, password, name, birthDay, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이메일 포맷이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenEmailFormatIsInvalid() { + // arrange + String loginId = "namjin123"; + String password = "qwer@1234"; + String name = "namjin"; + String birthDay = "1994-05-25"; + String email = "epemxksl@gmail-com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(loginId, password, name, birthDay, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일 포맷이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenBirthDateFormatIsInvalid() { + // arrange + String loginId = "namjin123"; + String password = "qwer@1234"; + String name = "namjin"; + String birthDay = "1994-005-25"; + String email = "epemxksl@gmail.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(loginId, password, name, birthDay, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ 8자 미만이면, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenPasswordIsShorterThan8() { + // arrange + String loginId = "namjin123"; + String password = "qwer@12"; + String name = "namjin"; + String birthDay = "1994-05-25"; + String email = "epemxksl@gmail.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(loginId, password, name, birthDay, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ 16자 초과이면, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenPasswordIsLongerThan16() { + // arrange + String loginId = "namjin123"; + String password = "qwer@123412341234"; + String name = "namjin"; + String birthDay = "1994-05-25"; + String email = "epemxksl@gmail.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(loginId, password, name, birthDay, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— ν—ˆμš©λ˜μ§€ μ•Šμ€ λ¬Έμžκ°€ ν¬ν•¨λ˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenPasswordContainsInvalidCharacters() { + // arrange + String loginId = "namjin123"; + String password = "qwer@1234ν•œκΈ€"; + String name = "namjin"; + String birthDay = "1994-05-25"; + String email = "epemxksl@gmail.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(loginId, password, name, birthDay, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— 생년월일이 ν¬ν•¨λ˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenPasswordContainsBirthDate() { + // arrange + String loginId = "namjin123"; + String password = "qwer@1994-05-25"; + String name = "namjin"; + String birthDay = "1994-05-25"; + String email = "epemxksl@gmail.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(loginId, password, name, birthDay, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} From 5ac9342fda3873baa5efb4b7cdda9aa24da9b63c Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 01:03:57 +0900 Subject: [PATCH 04/20] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/user/Password.java | 30 ++++ .../loopers/domain/user/SignupCommand.java | 9 ++ .../com/loopers/domain/user/UserModel.java | 79 +++++++---- .../com/loopers/domain/user/PasswordTest.java | 101 +++++++++++++ .../loopers/domain/user/UserModelTest.java | 133 +++--------------- 5 files changed, 208 insertions(+), 144 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/SignupCommand.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java new file mode 100644 index 00000000..d772fecd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java @@ -0,0 +1,30 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public class Password { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final String PASSWORD_PATTERN = "^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\",./<>?]*$"; + + public static void validate(String value, String birthday) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” 8자리 이상, 16자리 μ΄ν•˜μ΄μ–΄μ•Ό ν•©λ‹ˆλ‹€."); + } + + if (!value.matches(PASSWORD_PATTERN)) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜Έ ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + String birthdayWithoutDash = birthday.replace("-", ""); + if (value.contains(birthday) || value.contains(birthdayWithoutDash)) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜Έμ— 생년월일을 포함할 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/SignupCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/SignupCommand.java new file mode 100644 index 00000000..722946a9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/SignupCommand.java @@ -0,0 +1,9 @@ +package com.loopers.domain.user; + +public record SignupCommand( + String loginId, + String password, + String name, + String birthday, + String email +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index 90700095..b68979ab 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -3,9 +3,11 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; import lombok.Getter; +import lombok.NoArgsConstructor; import java.time.LocalDate; import java.time.format.DateTimeParseException; @@ -13,68 +15,83 @@ @Entity @Table(name = "users") @Getter +@NoArgsConstructor public class UserModel extends BaseEntity { + @Column(nullable = false, unique = true) private String loginId; + + @Column(nullable = false) private String password; + + @Column(nullable = false) private String name; + + @Column(nullable = false) private LocalDate birthday; + + @Column(nullable = false) private String email; - protected UserModel(){} + private static final String LOGIN_ID_PATTERN = "^[a-zA-Z0-9]+$"; + private static final String EMAIL_PATTERN = "^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"; public UserModel(String loginId, String password, String name, String birthday, String email){ + validateLoginId(loginId); + validateName(name); + validateEmail(email); + validatePassword(password); + + this.loginId = loginId; + this.name = name; + this.email = email; + this.password = password; + this.birthday = parseBirthday(birthday); + } + + private void validateLoginId(String loginId){ if(loginId == null || loginId.isBlank()){ throw new CoreException(ErrorType.BAD_REQUEST, "둜그인 IDλŠ” λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); } - - if(password == null || password.isBlank()){ - throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); + if(!loginId.matches(LOGIN_ID_PATTERN)){ + throw new CoreException(ErrorType.BAD_REQUEST, "둜그인 IDλŠ” 영문과 숫자만 ν—ˆμš©λ©λ‹ˆλ‹€."); } + } + private void validateName(String name){ if(name == null || name.isBlank()){ throw new CoreException(ErrorType.BAD_REQUEST, "이름은 λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); } + } + private void validateEmail(String email){ if(email == null || email.isBlank()){ throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); } - - if(birthday == null || birthday.isBlank()){ - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); - } - - // 이메일 ν˜•μ‹ 검증 - if(!email.matches("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$")){ + if(!email.matches(EMAIL_PATTERN)){ throw new CoreException(ErrorType.BAD_REQUEST, "이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); } + } - // λΉ„λ°€λ²ˆν˜Έ 자리수 검증 - if(password.length() < 8 || password.length() > 16){ - throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” 8자리 이상, 16자리 μ΄ν•˜μ΄μ–΄μ•Ό ν•©λ‹ˆλ‹€."); - } - - // λΉ„λ°€λ²ˆν˜Έμ— 영문 λŒ€μ†Œλ¬Έμž, 숫자, 특수문자만 ν¬ν•¨λ˜μ–΄ μžˆλŠ”μ§€ 검증 - if(!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\",./<>?]*$")){ - throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜Έ ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + private void validatePassword(String password){ + if(password == null || password.isBlank()){ + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); } + } - // λΉ„λ°€λ²ˆν˜Έμ— 생년월일이 ν¬ν•¨λ˜μ–΄ μžˆλŠ”μ§€ 검증 - String birthdayWithoutDash = birthday.replace("-", ""); - if(password.contains(birthday) || password.contains(birthdayWithoutDash)){ - throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜Έμ— 생년월일을 포함할 수 μ—†μŠ΅λ‹ˆλ‹€."); + private LocalDate parseBirthday(String birthday){ + if(birthday == null || birthday.isBlank()){ + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); } - + LocalDate parsed; try{ - this.birthday = LocalDate.parse(birthday); + parsed = LocalDate.parse(birthday); }catch(DateTimeParseException e){ throw new CoreException(ErrorType.BAD_REQUEST, "생년월일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); } - - this.loginId = loginId; - this.name = name; - this.password = password; - this.email = email; - + if(parsed.isAfter(LocalDate.now())){ + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래 λ‚ μ§œμΌ 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + return parsed; } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java new file mode 100644 index 00000000..dd91c8ab --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -0,0 +1,101 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PasswordTest { + + private static final String VALID_PASSWORD = "qwer@1234"; + private static final String VALID_BIRTHDAY = "1994-05-25"; + + @DisplayName("λΉ„λ°€λ²ˆν˜Έλ₯Ό 검증할 λ•Œ, ") + @Nested + class Validate { + + @DisplayName("정상적인 λΉ„λ°€λ²ˆν˜Έλ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•˜μ§€ μ•ŠλŠ”λ‹€.") + @Test + void doesNotThrowException_whenValid() { + // act & assert + assertDoesNotThrow(() -> Password.validate(VALID_PASSWORD, VALID_BIRTHDAY)); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ λΉ„μ–΄μžˆμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenPasswordIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Password.validate("", VALID_BIRTHDAY); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ 8자 미만이면, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenPasswordIsShorterThan8() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Password.validate("qwer@12", VALID_BIRTHDAY); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ 16자 초과이면, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenPasswordIsLongerThan16() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Password.validate("qwer@123412341234", VALID_BIRTHDAY); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— ν—ˆμš©λ˜μ§€ μ•Šμ€ λ¬Έμžκ°€ ν¬ν•¨λ˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenPasswordContainsInvalidCharacters() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Password.validate("qwer@1234ν•œκΈ€", VALID_BIRTHDAY); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— 생년월일(λŒ€μ‹œ 포함)이 ν¬ν•¨λ˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenPasswordContainsBirthdayWithDash() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Password.validate("qwer@1994-05-25", VALID_BIRTHDAY); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— 생년월일(λŒ€μ‹œ 미포함)이 ν¬ν•¨λ˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenPasswordContainsBirthdayWithoutDash() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Password.validate("qwer19940525", VALID_BIRTHDAY); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 710fc95a..c99a12c6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -11,6 +11,12 @@ class UserModelTest { + private static final String VALID_LOGIN_ID = "namjin123"; + private static final String VALID_PASSWORD = "qwer@1234"; + private static final String VALID_NAME = "namjin"; + private static final String VALID_BIRTHDAY = "1994-05-25"; + private static final String VALID_EMAIL = "epemxksl@gmail.com"; + @DisplayName("νšŒμ›μ„ 생성할 λ•Œ, ") @Nested class Create { @@ -18,15 +24,8 @@ class Create { @DisplayName("λͺ¨λ“  정보가 μ˜¬λ°”λ₯΄λ©΄, μ •μƒμ μœΌλ‘œ μƒμ„±λœλ‹€.") @Test void createsUser_whenAllInfoIsProvided() { - // arrange - String loginId = "namjin123"; - String password = "qwer@1234"; - String name = "namjin"; - String birthDay = "1994-05-25"; - String email = "epemxksl@gmail.com"; - // act - UserModel user = new UserModel(loginId, password, name, birthDay, email); + UserModel user = new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); // assert assertThat(user).isNotNull(); @@ -35,149 +34,57 @@ void createsUser_whenAllInfoIsProvided() { @DisplayName("둜그인 IDκ°€ λΉ„μ–΄μžˆμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") @Test void throwsBadRequestException_whenLoginIdIsBlank() { - // arrange - String loginId = ""; - String password = "qwer@1234"; - String name = "namjin"; - String birthDay = "1994-05-25"; - String email = "epemxksl@gmail.com"; - // act CoreException result = assertThrows(CoreException.class, () -> { - new UserModel(loginId, password, name, birthDay, email); + new UserModel("", VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); }); // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("이름이 λΉ„μ–΄μžˆμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @DisplayName("둜그인 ID에 영문과 숫자 μ™Έ λ¬Έμžκ°€ ν¬ν•¨λ˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") @Test - void throwsBadRequestException_whenNameIsBlank() { - // arrange - String loginId = "namjin123"; - String password = "qwer@1234"; - String name = ""; - String birthDay = "1994-05-25"; - String email = "epemxksl@gmail.com"; - + void throwsBadRequestException_whenLoginIdContainsInvalidCharacters() { // act CoreException result = assertThrows(CoreException.class, () -> { - new UserModel(loginId, password, name, birthDay, email); + new UserModel("namjin123!@#", VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); }); // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("이메일 포맷이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") - @Test - void throwsBadRequestException_whenEmailFormatIsInvalid() { - // arrange - String loginId = "namjin123"; - String password = "qwer@1234"; - String name = "namjin"; - String birthDay = "1994-05-25"; - String email = "epemxksl@gmail-com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new UserModel(loginId, password, name, birthDay, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("생년월일 포맷이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") - @Test - void throwsBadRequestException_whenBirthDateFormatIsInvalid() { - // arrange - String loginId = "namjin123"; - String password = "qwer@1234"; - String name = "namjin"; - String birthDay = "1994-005-25"; - String email = "epemxksl@gmail.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new UserModel(loginId, password, name, birthDay, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ 8자 미만이면, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") - @Test - void throwsBadRequestException_whenPasswordIsShorterThan8() { - // arrange - String loginId = "namjin123"; - String password = "qwer@12"; - String name = "namjin"; - String birthDay = "1994-05-25"; - String email = "epemxksl@gmail.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new UserModel(loginId, password, name, birthDay, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ 16자 초과이면, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @DisplayName("이름이 λΉ„μ–΄μžˆμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") @Test - void throwsBadRequestException_whenPasswordIsLongerThan16() { - // arrange - String loginId = "namjin123"; - String password = "qwer@123412341234"; - String name = "namjin"; - String birthDay = "1994-05-25"; - String email = "epemxksl@gmail.com"; - + void throwsBadRequestException_whenNameIsBlank() { // act CoreException result = assertThrows(CoreException.class, () -> { - new UserModel(loginId, password, name, birthDay, email); + new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, "", VALID_BIRTHDAY, VALID_EMAIL); }); // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— ν—ˆμš©λ˜μ§€ μ•Šμ€ λ¬Έμžκ°€ ν¬ν•¨λ˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @DisplayName("이메일 포맷이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") @Test - void throwsBadRequestException_whenPasswordContainsInvalidCharacters() { - // arrange - String loginId = "namjin123"; - String password = "qwer@1234ν•œκΈ€"; - String name = "namjin"; - String birthDay = "1994-05-25"; - String email = "epemxksl@gmail.com"; - + void throwsBadRequestException_whenEmailFormatIsInvalid() { // act CoreException result = assertThrows(CoreException.class, () -> { - new UserModel(loginId, password, name, birthDay, email); + new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, "epemxksl@gmail-com"); }); // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } - @DisplayName("λΉ„λ°€λ²ˆν˜Έμ— 생년월일이 ν¬ν•¨λ˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @DisplayName("생년월일 포맷이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") @Test - void throwsBadRequestException_whenPasswordContainsBirthDate() { - // arrange - String loginId = "namjin123"; - String password = "qwer@1994-05-25"; - String name = "namjin"; - String birthDay = "1994-05-25"; - String email = "epemxksl@gmail.com"; - + void throwsBadRequestException_whenBirthDateFormatIsInvalid() { // act CoreException result = assertThrows(CoreException.class, () -> { - new UserModel(loginId, password, name, birthDay, email); + new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, "1994-005-25", VALID_EMAIL); }); // assert From e488bd0a08f675269f8b9edb1158ed58d12a6f16 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 01:06:39 +0900 Subject: [PATCH 05/20] =?UTF-8?q?feat:=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=EB=B0=8F=20=EC=9D=B8?= =?UTF-8?q?=ED=94=84=EB=9D=BC=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84/=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 3 + .../com/loopers/config/SecurityConfig.java | 15 +++ .../loopers/domain/user/UserRepository.java | 8 ++ .../com/loopers/domain/user/UserService.java | 35 +++++++ .../user/UserJpaRepository.java | 10 ++ .../user/UserRepositoryImpl.java | 24 +++++ .../user/UserServiceIntegrationTest.java | 80 ++++++++++++++++ .../loopers/domain/user/UserServiceTest.java | 96 +++++++++++++++++++ 8 files changed, 271 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..c5dedd18 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -9,6 +9,9 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") + + // security (PasswordEncoder μ‚¬μš©) + implementation("org.springframework.security:spring-security-crypto") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") // querydsl diff --git a/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java new file mode 100644 index 00000000..52b04d23 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 00000000..03e9ca8e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + Optional findByLoginId(String loginId); + UserModel save(UserModel user); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 00000000..ae56e036 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,35 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + // νšŒμ›κ°€μž… + public UserModel signup(SignupCommand command){ + // 1. λΉ„λ°€λ²ˆν˜Έ 검증 (μ•”ν˜Έν™” μ „ raw password) + Password.validate(command.password(), command.birthday()); + + // 2. 쀑볡 체크 + Optional existedUser = userRepository.findByLoginId(command.loginId()); + if(existedUser.isPresent()){ + throw new CoreException(ErrorType.BAD_REQUEST, "이미 μ‘΄μž¬ν•˜λŠ” 둜그인 IDμž…λ‹ˆλ‹€."); + } + + // 3. λΉ„λ°€λ²ˆν˜Έ μ•”ν˜Έν™” + String encryptedPassword = passwordEncoder.encode(command.password()); + + // 4. νšŒμ› 생성 및 μ €μž₯ + UserModel newUser = new UserModel(command.loginId(), encryptedPassword, command.name(), command.birthday(), command.email()); + return userRepository.save(newUser); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 00000000..d754c3d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.UserModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 00000000..63a3fa52 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + private final UserJpaRepository userJpaRepository; + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(loginId); + } + + @Override + public UserModel save(UserModel user) { + return userJpaRepository.save(user); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 00000000..a7160e2e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,80 @@ +package com.loopers.domain.user; + +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class UserServiceIntegrationTest { + + private static final String VALID_LOGIN_ID = "namjin123"; + private static final String VALID_PASSWORD = "qwer@1234"; + private static final String VALID_NAME = "namjin"; + private static final String VALID_BIRTHDAY = "1994-05-25"; + private static final String VALID_EMAIL = "test@gmail.com"; + + @Autowired + private UserService userService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("νšŒμ›κ°€μž…μ„ ν•  λ•Œ, ") + @Nested + class Signup { + + @DisplayName("정상적인 μ •λ³΄λ‘œ νšŒμ›κ°€μž…μ΄ μ„±κ³΅ν•œλ‹€.") + @Test + void signupSucceeds_whenInfoIsValid() { + // arrange + SignupCommand command = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // act + UserModel result = userService.signup(command); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getId()).isNotNull(); + assertThat(result.getLoginId()).isEqualTo(command.loginId()); + + // DB에 μ‹€μ œλ‘œ μ €μž₯λλŠ”μ§€ 확인 + assertThat(userJpaRepository.findById(result.getId())).isPresent(); + } + + @DisplayName("이미 κ°€μž…λœ 둜그인 ID둜 κ°€μž…ν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenLoginIdAlreadyExists() { + // arrange - λ¨Όμ € νšŒμ› ν•˜λ‚˜ μ €μž₯ + UserModel existingUser = new UserModel(VALID_LOGIN_ID, "otherPw@123", "κΈ°μ‘΄νšŒμ›", "1990-01-01", "other@test.com"); + userJpaRepository.save(existingUser); + + // 같은 loginId둜 κ°€μž… μ‹œλ„ + SignupCommand command = new SignupCommand(VALID_LOGIN_ID, "newPw@1234", "μ‹ κ·œνšŒμ›", "1995-05-05", "new@test.com"); + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + userService.signup(command); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java new file mode 100644 index 00000000..10de5d78 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -0,0 +1,96 @@ +package com.loopers.domain.user; + +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.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + private static final String VALID_LOGIN_ID = "namjin123"; + private static final String VALID_PASSWORD = "qwer@1234"; + private static final String VALID_NAME = "namjin"; + private static final String VALID_BIRTHDAY = "1994-05-25"; + private static final String VALID_EMAIL = "epemxksl@gmail.com"; + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private UserService userService; + + @DisplayName("νšŒμ›κ°€μž…μ„ ν•  λ•Œ, ") + @Nested + class Signup { + + @DisplayName("정상적인 μ •λ³΄λ‘œ νšŒμ›κ°€μž…μ΄ μ„±κ³΅ν•œλ‹€.") + @Test + void signupSucceeds_whenInfoIsValid() { + // arrange + SignupCommand command = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // stub: findByLoginId ν˜ΈμΆœν•˜λ©΄ 빈 κ°’ λ°˜ν™˜ (ν•΄λ‹Ή μ•„μ΄λ””λ‘œ κ°€μž…λœ νšŒμ› μ—†μŒ) + when(userRepository.findByLoginId(command.loginId())) + .thenReturn(Optional.empty()); + + // stub: λΉ„λ°€λ²ˆν˜Έ μ•”ν˜Έν™” + when(passwordEncoder.encode(command.password())) + .thenReturn("encrypted_password"); + + // stub: save 호좜 μ‹œ μ €μž₯된 객체 λ°˜ν™˜ + when(userRepository.save(any(UserModel.class))) + .thenAnswer((invocation) -> invocation.getArgument(0)); + + // act + UserModel result = userService.signup(command); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo(command.loginId()); + + verify(userRepository, times(1)).save(any(UserModel.class)); + } + + @DisplayName("이미 κ°€μž…λœ 둜그인 ID둜 κ°€μž…ν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenLoginIdAlreadyExists() { + // arrange + SignupCommand command = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // 이미 μ‘΄μž¬ν•˜λŠ” νšŒμ› 생성 + UserModel existingUser = new UserModel(command.loginId(), "anonymous@123", "κΈ°μ‘΄νšŒμ›", "1990-01-01", "anonymous@gmail.com"); + + // stub: IDκ°€ μ€‘λ³΅λ˜λŠ” 이미 μ‘΄μž¬ν•˜λŠ” UserModel객체 λ°˜ν™˜ + when(userRepository.findByLoginId(command.loginId())) + .thenReturn(Optional.of(existingUser)); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.signup(command); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + + // ν–‰μœ„ 검증 + verify(userRepository, never()).save(any()); + } + } +} From 6e9f89f38e4cef06ff8177f8008a98098b19446d Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 01:07:33 +0900 Subject: [PATCH 06/20] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EB=B0=8F=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/user/UserFacade.java | 18 ++++ .../loopers/application/user/UserInfo.java | 23 ++++ .../interfaces/api/user/UserV1ApiSpec.java | 15 +++ .../interfaces/api/user/UserV1Controller.java | 36 +++++++ .../interfaces/api/user/UserV1Dto.java | 32 ++++++ .../interfaces/api/UserV1ApiE2ETest.java | 102 ++++++++++++++++++ 6 files changed, 226 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 00000000..438c3a13 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,18 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.SignupCommand; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserFacade { + private final UserService userService; + + public UserInfo signUp(SignupCommand command){ + UserModel user = userService.signup(command); + return UserInfo.from(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 00000000..093ef929 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.UserModel; + +import java.time.LocalDate; + +public record UserInfo( + Long id, + String loginId, + String name, + LocalDate birthday, + String email +) { + public static UserInfo from(UserModel userModel) { + return new UserInfo( + userModel.getId(), + userModel.getLoginId(), + userModel.getName(), + userModel.getBirthday(), + userModel.getEmail() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java new file mode 100644 index 00000000..ec0bc1d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User V1 API", description = "νšŒμ› κ΄€λ ¨ API μž…λ‹ˆλ‹€.") +public interface UserV1ApiSpec { + + @Operation( + summary = "νšŒμ›κ°€μž…", + description = "μƒˆλ‘œμš΄ νšŒμ›μ„ λ“±λ‘ν•©λ‹ˆλ‹€." + ) + ApiResponse signup(UserV1Dto.SignupRequest request); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 00000000..ada526b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.SignupCommand; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private final UserFacade userFacade; + + @PostMapping("/signup") + @Override + public ApiResponse signup( + @RequestBody UserV1Dto.SignupRequest request + ) { + SignupCommand command = new SignupCommand( + request.loginId(), + request.password(), + request.name(), + request.birthday(), + request.email() + ); + UserInfo info = userFacade.signUp(command); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 00000000..ed07750e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; + +import java.time.LocalDate; + +public class UserV1Dto { + + public record SignupRequest( + String loginId, + String password, + String name, + String birthday, + String email + ) {} + + public record UserResponse( + String loginId, + String name, + LocalDate birthday, + String email + ) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.loginId(), + info.name(), + info.birthday(), + info.email() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java new file mode 100644 index 00000000..80b4a227 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,102 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + + private static final String SIGNUP_ENDPOINT = "/api/v1/users/signup"; + private static final String VALID_LOGIN_ID = "namjin123"; + private static final String VALID_PASSWORD = "qwer@1234"; + private static final String VALID_NAME = "namjin"; + private static final String VALID_BIRTHDAY = "1994-05-25"; + private static final String VALID_EMAIL = "test@gmail.com"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/users/signup") + @Nested + class Signup { + + @DisplayName("정상적인 μ •λ³΄λ‘œ νšŒμ›κ°€μž…ν•˜λ©΄, 200 OK와 νšŒμ› 정보λ₯Ό λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsUserInfo_whenSignupIsValid() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().loginId()).isEqualTo(VALID_LOGIN_ID), + () -> assertThat(response.getBody().data().name()).isEqualTo(VALID_NAME), + () -> assertThat(response.getBody().data().email()).isEqualTo(VALID_EMAIL) + ); + } + + @DisplayName("이미 μ‘΄μž¬ν•˜λŠ” 둜그인 ID둜 κ°€μž…ν•˜λ©΄, 400 BAD_REQUEST 응닡을 λ°›λŠ”λ‹€.") + @Test + void throwsBadRequest_whenLoginIdAlreadyExists() { + // arrange - λ¨Όμ € νšŒμ›κ°€μž… + UserV1Dto.SignupRequest firstRequest = new UserV1Dto.SignupRequest( + VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(firstRequest), + new ParameterizedTypeReference>() {}); + + // 같은 loginId둜 λ‹€μ‹œ κ°€μž… μ‹œλ„ + UserV1Dto.SignupRequest duplicateRequest = new UserV1Dto.SignupRequest( + VALID_LOGIN_ID, "other@1234", "λ‹€λ₯Έμ‚¬λžŒ", "1990-01-01", "other@gmail.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(duplicateRequest), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } +} From f42cce580e562ebd73f1e1bf920de1f404f67666 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 01:08:11 +0900 Subject: [PATCH 07/20] =?UTF-8?q?docs:=20claude.md=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?-=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EC=83=81=EC=88=98=20=EC=84=A0=EC=96=B8=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 119e1ad7..d2b946e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,7 @@ Entity β†’ ExampleInfo (application DTO) β†’ ExampleV1Dto (API DTO) β†’ ApiRespo - `DatabaseCleanUp` ν”½μŠ€μ²˜λ‘œ ν…ŒμŠ€νŠΈ κ°„ 데이터 정리 (`@AfterEach`) - JaCoCo μ½”λ“œ 컀버리지 (XML 리포트) - ν”„λ‘œνŒŒμΌ: `test`, 순차 μ‹€ν–‰ (`maxParallelForks = 1`) +- ν…ŒμŠ€νŠΈ 데이터 쀑 μ—¬λŸ¬ ν…ŒμŠ€νŠΈμ—μ„œ 반볡 μ‚¬μš©λ˜λŠ” 값은 클래슀 레벨 μƒμˆ˜(`private static final`)둜 μ„ μ–Έν•œλ‹€ ### μ½”λ“œ μŠ€νƒ€μΌ - Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Slf4j` From c2620b802d6dc202af7b02f2a16ff69fd5b41c8a Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 14:28:20 +0900 Subject: [PATCH 08/20] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=84=B0=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/config/AuthInterceptor.java | 36 +++++++ .../java/com/loopers/config/WebMvcConfig.java | 20 ++++ .../com/loopers/support/error/ErrorType.java | 1 + .../loopers/config/AuthInterceptorTest.java | 94 +++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/AuthInterceptor.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/config/AuthInterceptorTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/config/AuthInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/config/AuthInterceptor.java new file mode 100644 index 00000000..da4934ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/AuthInterceptor.java @@ -0,0 +1,36 @@ +package com.loopers.config; + +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@RequiredArgsConstructor +@Component +public class AuthInterceptor implements HandlerInterceptor { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + public static final String ATTR_LOGIN_ID = "loginId"; + + private final UserService userService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String loginId = request.getHeader(HEADER_LOGIN_ID); + String loginPw = request.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank() || loginPw == null || loginPw.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헀더가 λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + userService.authenticate(loginId, loginPw); + request.setAttribute(ATTR_LOGIN_ID, loginId); + + return true; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java new file mode 100644 index 00000000..1a59990e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.loopers.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthInterceptor authInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authInterceptor) + .addPathPatterns("/api/**") + .excludePathPatterns("/api/v1/users/signup"); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efb..8d493491 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -10,6 +10,7 @@ public enum ErrorType { /** λ²”μš© μ—λŸ¬ */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "μΌμ‹œμ μΈ 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘λͺ»λœ μš”μ²­μž…λ‹ˆλ‹€."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μš”μ²­μž…λ‹ˆλ‹€."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 μ‘΄μž¬ν•˜λŠ” λ¦¬μ†ŒμŠ€μž…λ‹ˆλ‹€."); diff --git a/apps/commerce-api/src/test/java/com/loopers/config/AuthInterceptorTest.java b/apps/commerce-api/src/test/java/com/loopers/config/AuthInterceptorTest.java new file mode 100644 index 00000000..7c88566f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/config/AuthInterceptorTest.java @@ -0,0 +1,94 @@ +package com.loopers.config; + +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuthInterceptorTest { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + private static final String VALID_LOGIN_ID = "namjin123"; + private static final String VALID_PASSWORD = "qwer@1234"; + + @Mock + private UserService userService; + + @InjectMocks + private AuthInterceptor authInterceptor; + + @DisplayName("인증 μΈν„°μ…‰ν„°μ—μ„œ, ") + @Nested + class PreHandle { + + @DisplayName("μœ νš¨ν•œ 인증 ν—€λ”λ‘œ μš”μ²­ν•˜λ©΄, 인증에 μ„±κ³΅ν•˜κ³  trueλ₯Ό λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsTrue_whenCredentialsAreValid() { + // arrange + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + + when(request.getHeader(HEADER_LOGIN_ID)).thenReturn(VALID_LOGIN_ID); + when(request.getHeader(HEADER_LOGIN_PW)).thenReturn(VALID_PASSWORD); + + // act + boolean result = authInterceptor.preHandle(request, response, new Object()); + + // assert + assertThat(result).isTrue(); + verify(userService).authenticate(VALID_LOGIN_ID, VALID_PASSWORD); + verify(request).setAttribute(AuthInterceptor.ATTR_LOGIN_ID, VALID_LOGIN_ID); + } + + @DisplayName("인증 헀더가 λˆ„λ½λ˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenHeadersMissing() { + // arrange + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + authInterceptor.preHandle(request, response, new Object()); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + verify(userService, never()).authenticate(any(), any()); + } + + @DisplayName("인증에 μ‹€νŒ¨ν•˜λ©΄, μ˜ˆμ™Έκ°€ μ „νŒŒλœλ‹€.") + @Test + void throwsException_whenAuthenticationFails() { + // arrange + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + + when(request.getHeader(HEADER_LOGIN_ID)).thenReturn(VALID_LOGIN_ID); + when(request.getHeader(HEADER_LOGIN_PW)).thenReturn("wrongPassword"); + + when(userService.authenticate(VALID_LOGIN_ID, "wrongPassword")) + .thenThrow(new CoreException(ErrorType.UNAUTHORIZED, "νšŒμ› 정보가 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")); + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + authInterceptor.preHandle(request, response, new Object()); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + } +} From d3cdf7143934a9a8646d025a836e37a7ce9e7eaf Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 14:30:16 +0900 Subject: [PATCH 09/20] =?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=20=EB=B0=8F=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B0=8F=20=EA=B8=B0=EB=8A=A5=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/application/user/UserFacade.java | 11 + .../domain/user/ChangePasswordCommand.java | 3 + .../com/loopers/domain/user/UserModel.java | 5 + .../com/loopers/domain/user/UserService.java | 45 +++- .../interfaces/api/user/UserV1ApiSpec.java | 12 + .../interfaces/api/user/UserV1Controller.java | 24 ++ .../interfaces/api/user/UserV1Dto.java | 14 +- .../loopers/domain/user/UserModelTest.java | 61 +++++ .../user/UserServiceIntegrationTest.java | 154 +++++++++++ .../loopers/domain/user/UserServiceTest.java | 224 +++++++++++++++- .../interfaces/api/UserV1ApiE2ETest.java | 242 +++++++++++++++++- 11 files changed, 791 insertions(+), 4 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/ChangePasswordCommand.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 438c3a13..9207b668 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.user; +import com.loopers.domain.user.ChangePasswordCommand; import com.loopers.domain.user.SignupCommand; import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserService; @@ -15,4 +16,14 @@ public UserInfo signUp(SignupCommand command){ UserModel user = userService.signup(command); return UserInfo.from(user); } + + public UserInfo getMyInfo(String loginId) { + UserModel user = userService.findByLoginId(loginId); + return UserInfo.from(user); + } + + public void changePassword(String loginId, String currentPassword, String newPassword) { + ChangePasswordCommand command = new ChangePasswordCommand(loginId, currentPassword, newPassword); + userService.changePassword(command); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/ChangePasswordCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/ChangePasswordCommand.java new file mode 100644 index 00000000..92b86805 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/ChangePasswordCommand.java @@ -0,0 +1,3 @@ +package com.loopers.domain.user; + +public record ChangePasswordCommand(String loginId, String currentPassword, String newPassword) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index b68979ab..7c7b0e3d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -79,6 +79,11 @@ private void validatePassword(String password){ } } + public void changePassword(String encryptedPassword) { + validatePassword(encryptedPassword); + this.password = encryptedPassword; + } + private LocalDate parseBirthday(String birthday){ if(birthday == null || birthday.isBlank()){ throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index ae56e036..d84e0fcd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -22,7 +22,7 @@ public UserModel signup(SignupCommand command){ // 2. 쀑볡 체크 Optional existedUser = userRepository.findByLoginId(command.loginId()); if(existedUser.isPresent()){ - throw new CoreException(ErrorType.BAD_REQUEST, "이미 μ‘΄μž¬ν•˜λŠ” 둜그인 IDμž…λ‹ˆλ‹€."); + throw new CoreException(ErrorType.CONFLICT, "이미 μ‘΄μž¬ν•˜λŠ” 둜그인 IDμž…λ‹ˆλ‹€."); } // 3. λΉ„λ°€λ²ˆν˜Έ μ•”ν˜Έν™” @@ -32,4 +32,47 @@ public UserModel signup(SignupCommand command){ UserModel newUser = new UserModel(command.loginId(), encryptedPassword, command.name(), command.birthday(), command.email()); return userRepository.save(newUser); } + + // 인증 + public UserModel authenticate(String loginId, String password) { + UserModel user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "νšŒμ› 정보가 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")); + + if (!passwordEncoder.matches(password, user.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "νšŒμ› 정보가 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + return user; + } + + // λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ + public void changePassword(ChangePasswordCommand command) { + // 1. μ‚¬μš©μž 쑰회 + UserModel user = userRepository.findByLoginId(command.loginId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ‚¬μš©μžμž…λ‹ˆλ‹€.")); + + // 2. ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 확인 + if (!passwordEncoder.matches(command.currentPassword(), user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + // 3. μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•œμ§€ 확인 + if (passwordEncoder.matches(command.newPassword(), user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "μƒˆ λΉ„λ°€λ²ˆν˜ΈλŠ” ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ 동일할 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + // 4. λΉ„λ°€λ²ˆν˜Έ κ·œμΉ™ 검증 + Password.validate(command.newPassword(), user.getBirthday().toString()); + + // 5. μ•”ν˜Έν™” 및 μ €μž₯ + String encryptedPassword = passwordEncoder.encode(command.newPassword()); + user.changePassword(encryptedPassword); + userRepository.save(user); + } + + // 둜그인 ID둜 쑰회 + public UserModel findByLoginId(String loginId) { + return userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ‚¬μš©μžμž…λ‹ˆλ‹€.")); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index ec0bc1d8..6c32ff1c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -12,4 +12,16 @@ public interface UserV1ApiSpec { description = "μƒˆλ‘œμš΄ νšŒμ›μ„ λ“±λ‘ν•©λ‹ˆλ‹€." ) ApiResponse signup(UserV1Dto.SignupRequest request); + + @Operation( + summary = "λ‚΄ 정보 쑰회", + description = "인증된 μ‚¬μš©μžμ˜ 정보λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€." + ) + ApiResponse getMe(String loginId); + + @Operation( + summary = "λΉ„λ°€λ²ˆν˜Έ λ³€κ²½", + description = "인증된 μ‚¬μš©μžμ˜ λΉ„λ°€λ²ˆν˜Έλ₯Ό λ³€κ²½ν•©λ‹ˆλ‹€." + ) + ApiResponse changePassword(String loginId, UserV1Dto.ChangePasswordRequest request); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index ada526b2..b6bcc4ac 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -2,10 +2,14 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; +import com.loopers.config.AuthInterceptor; import com.loopers.domain.user.SignupCommand; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -33,4 +37,24 @@ public ApiResponse signup( UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); return ApiResponse.success(response); } + + @GetMapping("/me") + @Override + public ApiResponse getMe( + @RequestAttribute(AuthInterceptor.ATTR_LOGIN_ID) String loginId + ) { + UserInfo info = userFacade.getMyInfo(loginId); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); + return ApiResponse.success(response); + } + + @PatchMapping("/password") + @Override + public ApiResponse changePassword( + @RequestAttribute(AuthInterceptor.ATTR_LOGIN_ID) String loginId, + @RequestBody UserV1Dto.ChangePasswordRequest request + ) { + userFacade.changePassword(loginId, request.currentPassword(), request.newPassword()); + return ApiResponse.success(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index ed07750e..31c33352 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -14,6 +14,11 @@ public record SignupRequest( String email ) {} + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} + public record UserResponse( String loginId, String name, @@ -23,10 +28,17 @@ public record UserResponse( public static UserResponse from(UserInfo info) { return new UserResponse( info.loginId(), - info.name(), + maskLastCharacter(info.name()), info.birthday(), info.email() ); } + + private static String maskLastCharacter(String value) { + if (value == null || value.length() <= 1) { + return value; + } + return value.substring(0, value.length() - 1) + "*"; + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index c99a12c6..2fc80b8c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -90,5 +90,66 @@ void throwsBadRequestException_whenBirthDateFormatIsInvalid() { // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } + + @DisplayName("생년월일이 ν˜„μž¬λ³΄λ‹€ μ΄ν›„μ˜ λ‚ μ§œλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenBirthDateisAfterToday(){ + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, "2027-01-01", VALID_EMAIL); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έλ₯Ό λ³€κ²½ν•  λ•Œ, ") + @Nested + class ChangePassword { + + @DisplayName("정상적인 μ•”ν˜Έν™”λœ λΉ„λ°€λ²ˆν˜Έλ‘œ λ³€κ²½ν•˜λ©΄, λΉ„λ°€λ²ˆν˜Έκ°€ μ—…λ°μ΄νŠΈλœλ‹€.") + @Test + void changesPassword_whenEncryptedPasswordIsValid() { + // arrange + UserModel user = new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + String newEncryptedPassword = "new_encrypted_password"; + + // act + user.changePassword(newEncryptedPassword); + + // assert + assertThat(user.getPassword()).isEqualTo(newEncryptedPassword); + } + + @DisplayName("blank λΉ„λ°€λ²ˆν˜Έλ‘œ λ³€κ²½ν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenPasswordIsBlank() { + // arrange + UserModel user = new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + user.changePassword(""); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null λΉ„λ°€λ²ˆν˜Έλ‘œ λ³€κ²½ν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenPasswordIsNull() { + // arrange + UserModel user = new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + user.changePassword(null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index a7160e2e..d9a355c3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -77,4 +77,158 @@ void throwsException_whenLoginIdAlreadyExists() { assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } + + @DisplayName("인증을 ν•  λ•Œ, ") + @Nested + class Authenticate { + + @DisplayName("μ˜¬λ°”λ₯Έ 자격증λͺ…μœΌλ‘œ 인증이 μ„±κ³΅ν•œλ‹€.") + @Test + void authenticateSucceeds_whenCredentialsAreValid() { + // arrange - νšŒμ›κ°€μž… (BCrypt μ•”ν˜Έν™” 포함) + SignupCommand command = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + userService.signup(command); + + // act + UserModel result = userService.authenticate(VALID_LOGIN_ID, VALID_PASSWORD); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 둜그인 ID둜 μΈμ¦ν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenLoginIdNotFound() { + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + userService.authenticate("nonexistent", VALID_PASSWORD); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenPasswordDoesNotMatch() { + // arrange - νšŒμ›κ°€μž… + SignupCommand command = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + userService.signup(command); + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + userService.authenticate(VALID_LOGIN_ID, "wrongPw@123"); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έλ₯Ό λ³€κ²½ν•  λ•Œ, ") + @Nested + class ChangePassword { + + private static final String NEW_PASSWORD = "newPw@1234"; + + @DisplayName("정상적인 μ •λ³΄λ‘œ λΉ„λ°€λ²ˆν˜Έ 변경이 μ„±κ³΅ν•œλ‹€.") + @Test + void changePasswordSucceeds_whenInfoIsValid() { + // arrange - νšŒμ›κ°€μž… + SignupCommand signupCommand = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + userService.signup(signupCommand); + + ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); + + // act + userService.changePassword(command); + + // assert - μƒˆ λΉ„λ°€λ²ˆν˜Έλ‘œ 인증 성곡 + UserModel result = userService.authenticate(VALID_LOGIN_ID, NEW_PASSWORD); + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ ν›„ κΈ°μ‘΄ λΉ„λ°€λ²ˆν˜Έλ‘œ μΈμ¦ν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenAuthenticatingWithOldPassword() { + // arrange - νšŒμ›κ°€μž… 및 λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ + SignupCommand signupCommand = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + userService.signup(signupCommand); + + ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); + userService.changePassword(command); + + // act & assert - κΈ°μ‘΄ λΉ„λ°€λ²ˆν˜Έλ‘œ 인증 μ‹€νŒ¨ + CoreException result = assertThrows(CoreException.class, () -> { + userService.authenticate(VALID_LOGIN_ID, VALID_PASSWORD); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenCurrentPasswordDoesNotMatch() { + // arrange - νšŒμ›κ°€μž… + SignupCommand signupCommand = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + userService.signup(signupCommand); + + ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, "wrongPw@123", NEW_PASSWORD); + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(command); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenNewPasswordIsSameAsCurrent() { + // arrange - νšŒμ›κ°€μž… + SignupCommand signupCommand = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + userService.signup(signupCommand); + + ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_PASSWORD); + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(command); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("둜그인 ID둜 μ‘°νšŒν•  λ•Œ, ") + @Nested + class FindByLoginId { + + @DisplayName("μ‘΄μž¬ν•˜λŠ” 둜그인 ID둜 μ‘°νšŒν•˜λ©΄, μ‚¬μš©μžλ₯Ό λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsUser_whenLoginIdExists() { + // arrange - νšŒμ›κ°€μž… + SignupCommand command = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + userService.signup(command); + + // act + UserModel result = userService.findByLoginId(VALID_LOGIN_ID); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 둜그인 ID둜 μ‘°νšŒν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenLoginIdNotFound() { + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + userService.findByLoginId("nonexistent"); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index 10de5d78..8fa3062c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -23,6 +23,7 @@ class UserServiceTest { private static final String VALID_LOGIN_ID = "namjin123"; private static final String VALID_PASSWORD = "qwer@1234"; + private static final String VALID_ENCRYPTED_PASSWORD = "encrypted_password"; private static final String VALID_NAME = "namjin"; private static final String VALID_BIRTHDAY = "1994-05-25"; private static final String VALID_EMAIL = "epemxksl@gmail.com"; @@ -87,10 +88,231 @@ void throwsException_whenLoginIdAlreadyExists() { }); // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); // ν–‰μœ„ 검증 verify(userRepository, never()).save(any()); } } + + @DisplayName("인증을 ν•  λ•Œ, ") + @Nested + class Authenticate { + + @DisplayName("μ˜¬λ°”λ₯Έ 둜그인 ID와 λΉ„λ°€λ²ˆν˜Έλ‘œ 인증이 μ„±κ³΅ν•œλ‹€.") + @Test + void authenticateSucceeds_whenCredentialsAreValid() { + // arrange + UserModel user = new UserModel(VALID_LOGIN_ID, VALID_ENCRYPTED_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + when(userRepository.findByLoginId(VALID_LOGIN_ID)) + .thenReturn(Optional.of(user)); + when(passwordEncoder.matches(VALID_PASSWORD, VALID_ENCRYPTED_PASSWORD)) + .thenReturn(true); + + // act + UserModel result = userService.authenticate(VALID_LOGIN_ID, VALID_PASSWORD); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 둜그인 ID둜 μΈμ¦ν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenLoginIdNotFound() { + // arrange + when(userRepository.findByLoginId(VALID_LOGIN_ID)) + .thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.authenticate(VALID_LOGIN_ID, VALID_PASSWORD); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenPasswordDoesNotMatch() { + // arrange + UserModel user = new UserModel(VALID_LOGIN_ID, VALID_ENCRYPTED_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + when(userRepository.findByLoginId(VALID_LOGIN_ID)) + .thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrongPassword", VALID_ENCRYPTED_PASSWORD)) + .thenReturn(false); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.authenticate(VALID_LOGIN_ID, "wrongPassword"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έλ₯Ό λ³€κ²½ν•  λ•Œ, ") + @Nested + class ChangePassword { + + private static final String NEW_PASSWORD = "newPw@1234"; + private static final String NEW_ENCRYPTED_PASSWORD = "new_encrypted_password"; + + @DisplayName("정상적인 μ •λ³΄λ‘œ λΉ„λ°€λ²ˆν˜Έ 변경이 μ„±κ³΅ν•œλ‹€.") + @Test + void changePasswordSucceeds_whenInfoIsValid() { + // arrange + ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); + UserModel user = new UserModel(VALID_LOGIN_ID, VALID_ENCRYPTED_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + when(userRepository.findByLoginId(VALID_LOGIN_ID)) + .thenReturn(Optional.of(user)); + when(passwordEncoder.matches(VALID_PASSWORD, VALID_ENCRYPTED_PASSWORD)) + .thenReturn(true); + when(passwordEncoder.matches(NEW_PASSWORD, VALID_ENCRYPTED_PASSWORD)) + .thenReturn(false); + when(passwordEncoder.encode(NEW_PASSWORD)) + .thenReturn(NEW_ENCRYPTED_PASSWORD); + when(userRepository.save(any(UserModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + userService.changePassword(command); + + // assert + verify(userRepository, times(1)).save(any(UserModel.class)); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ‚¬μš©μžμ΄λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenUserNotFound() { + // arrange + ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); + + when(userRepository.findByLoginId(VALID_LOGIN_ID)) + .thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(command); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(userRepository, never()).save(any()); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenCurrentPasswordDoesNotMatch() { + // arrange + ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, "wrongPw@123", NEW_PASSWORD); + UserModel user = new UserModel(VALID_LOGIN_ID, VALID_ENCRYPTED_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + when(userRepository.findByLoginId(VALID_LOGIN_ID)) + .thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrongPw@123", VALID_ENCRYPTED_PASSWORD)) + .thenReturn(false); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(command); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(userRepository, never()).save(any()); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenNewPasswordIsSameAsCurrent() { + // arrange + ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_PASSWORD); + UserModel user = new UserModel(VALID_LOGIN_ID, VALID_ENCRYPTED_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + when(userRepository.findByLoginId(VALID_LOGIN_ID)) + .thenReturn(Optional.of(user)); + when(passwordEncoder.matches(VALID_PASSWORD, VALID_ENCRYPTED_PASSWORD)) + .thenReturn(true); + // μƒˆ λΉ„λ°€λ²ˆν˜Έλ„ ν˜„μž¬μ™€ 동일 + when(passwordEncoder.matches(VALID_PASSWORD, VALID_ENCRYPTED_PASSWORD)) + .thenReturn(true); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(command); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(userRepository, never()).save(any()); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ κ·œμΉ™μ— μœ„λ°˜λ˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenNewPasswordViolatesRules() { + // arrange - λ„ˆλ¬΄ 짧은 λΉ„λ°€λ²ˆν˜Έ + ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, "short"); + UserModel user = new UserModel(VALID_LOGIN_ID, VALID_ENCRYPTED_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + when(userRepository.findByLoginId(VALID_LOGIN_ID)) + .thenReturn(Optional.of(user)); + when(passwordEncoder.matches(VALID_PASSWORD, VALID_ENCRYPTED_PASSWORD)) + .thenReturn(true); + when(passwordEncoder.matches("short", VALID_ENCRYPTED_PASSWORD)) + .thenReturn(false); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(command); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(userRepository, never()).save(any()); + } + } + + @DisplayName("둜그인 ID둜 μ‘°νšŒν•  λ•Œ, ") + @Nested + class FindByLoginId { + + @DisplayName("μ‘΄μž¬ν•˜λŠ” 둜그인 ID둜 μ‘°νšŒν•˜λ©΄, μ‚¬μš©μžλ₯Ό λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsUser_whenLoginIdExists() { + // arrange + UserModel user = new UserModel(VALID_LOGIN_ID, VALID_ENCRYPTED_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + when(userRepository.findByLoginId(VALID_LOGIN_ID)) + .thenReturn(Optional.of(user)); + + // act + UserModel result = userService.findByLoginId(VALID_LOGIN_ID); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 둜그인 ID둜 μ‘°νšŒν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenLoginIdNotFound() { + // arrange + when(userRepository.findByLoginId(VALID_LOGIN_ID)) + .thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.findByLoginId(VALID_LOGIN_ID); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index 80b4a227..d8af0c83 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -11,7 +11,9 @@ 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 static org.assertj.core.api.Assertions.assertThat; @@ -22,9 +24,14 @@ class UserV1ApiE2ETest { private static final String SIGNUP_ENDPOINT = "/api/v1/users/signup"; + private static final String ME_ENDPOINT = "/api/v1/users/me"; + private static final String CHANGE_PASSWORD_ENDPOINT = "/api/v1/users/password"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; private static final String VALID_LOGIN_ID = "namjin123"; private static final String VALID_PASSWORD = "qwer@1234"; private static final String VALID_NAME = "namjin"; + private static final String EXPECTED_MASKED_NAME = "namji*"; private static final String VALID_BIRTHDAY = "1994-05-25"; private static final String VALID_EMAIL = "test@gmail.com"; @@ -67,7 +74,7 @@ void returnsUserInfo_whenSignupIsValid() { () -> assertTrue(response.getStatusCode().is2xxSuccessful()), () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), () -> assertThat(response.getBody().data().loginId()).isEqualTo(VALID_LOGIN_ID), - () -> assertThat(response.getBody().data().name()).isEqualTo(VALID_NAME), + () -> assertThat(response.getBody().data().name()).isEqualTo(EXPECTED_MASKED_NAME), () -> assertThat(response.getBody().data().email()).isEqualTo(VALID_EMAIL) ); } @@ -99,4 +106,237 @@ void throwsBadRequest_whenLoginIdAlreadyExists() { ); } } + + @DisplayName("GET /api/v1/users/me") + @Nested + class GetMe { + + @DisplayName("인증된 μ‚¬μš©μžκ°€ λ‚΄ 정보λ₯Ό μ‘°νšŒν•˜λ©΄, 200 OK와 νšŒμ› 정보λ₯Ό λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsUserInfo_whenAuthenticated() { + // arrange - νšŒμ›κ°€μž… + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(signupRequest), + new ParameterizedTypeReference>() {}); + + // 인증 헀더 μ„€μ • + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, VALID_LOGIN_ID); + headers.set(HEADER_LOGIN_PW, VALID_PASSWORD); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ME_ENDPOINT, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().loginId()).isEqualTo(VALID_LOGIN_ID), + () -> assertThat(response.getBody().data().name()).isEqualTo(EXPECTED_MASKED_NAME), + () -> assertThat(response.getBody().data().email()).isEqualTo(VALID_EMAIL) + ); + } + + @DisplayName("인증 헀더가 λˆ„λ½λ˜λ©΄, 401 UNAUTHORIZED 응닡을 λ°›λŠ”λ‹€.") + @Test + void returnsUnauthorized_whenHeadersMissing() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ME_ENDPOINT, HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("잘λͺ»λœ λΉ„λ°€λ²ˆν˜Έλ‘œ μš”μ²­ν•˜λ©΄, 401 UNAUTHORIZED 응닡을 λ°›λŠ”λ‹€.") + @Test + void returnsUnauthorized_whenPasswordIsWrong() { + // arrange - νšŒμ›κ°€μž… + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(signupRequest), + new ParameterizedTypeReference>() {}); + + // 잘λͺ»λœ λΉ„λ°€λ²ˆν˜Έλ‘œ 인증 헀더 μ„€μ • + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, VALID_LOGIN_ID); + headers.set(HEADER_LOGIN_PW, "wrongPw@123"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ME_ENDPOINT, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @DisplayName("PATCH /api/v1/users/password") + @Nested + class ChangePassword { + + private static final String NEW_PASSWORD = "newPw@1234"; + + @DisplayName("정상적인 μ •λ³΄λ‘œ λΉ„λ°€λ²ˆν˜Έλ₯Ό λ³€κ²½ν•˜λ©΄, 200 OKλ₯Ό λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsSuccess_whenChangePasswordIsValid() { + // arrange - νšŒμ›κ°€μž… + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(signupRequest), + new ParameterizedTypeReference>() {}); + + // 인증 헀더 μ„€μ • + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, VALID_LOGIN_ID); + headers.set(HEADER_LOGIN_PW, VALID_PASSWORD); + headers.set("Content-Type", "application/json"); + + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest(VALID_PASSWORD, NEW_PASSWORD); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμœΌλ©΄, 400 BAD_REQUEST 응닡을 λ°›λŠ”λ‹€.") + @Test + void returnsBadRequest_whenCurrentPasswordDoesNotMatch() { + // arrange - νšŒμ›κ°€μž… + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(signupRequest), + new ParameterizedTypeReference>() {}); + + // 인증 헀더 μ„€μ • + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, VALID_LOGIN_ID); + headers.set(HEADER_LOGIN_PW, VALID_PASSWORD); + headers.set("Content-Type", "application/json"); + + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("wrongPw@123", NEW_PASSWORD); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(request, headers), 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 returnsBadRequest_whenNewPasswordIsSameAsCurrent() { + // arrange - νšŒμ›κ°€μž… + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(signupRequest), + new ParameterizedTypeReference>() {}); + + // 인증 헀더 μ„€μ • + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, VALID_LOGIN_ID); + headers.set(HEADER_LOGIN_PW, VALID_PASSWORD); + headers.set("Content-Type", "application/json"); + + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest(VALID_PASSWORD, VALID_PASSWORD); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("인증 헀더가 λˆ„λ½λ˜λ©΄, 401 UNAUTHORIZED 응닡을 λ°›λŠ”λ‹€.") + @Test + void returnsUnauthorized_whenHeadersMissing() { + // arrange + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest(VALID_PASSWORD, NEW_PASSWORD); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/json"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ ν›„ μƒˆ λΉ„λ°€λ²ˆν˜Έλ‘œ λ‚΄ 정보 μ‘°νšŒκ°€ μ„±κ³΅ν•œλ‹€.") + @Test + void canAccessMeWithNewPassword_afterPasswordChange() { + // arrange - νšŒμ›κ°€μž… + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(signupRequest), + new ParameterizedTypeReference>() {}); + + // λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ + HttpHeaders changeHeaders = new HttpHeaders(); + changeHeaders.set(HEADER_LOGIN_ID, VALID_LOGIN_ID); + changeHeaders.set(HEADER_LOGIN_PW, VALID_PASSWORD); + changeHeaders.set("Content-Type", "application/json"); + + UserV1Dto.ChangePasswordRequest changeRequest = new UserV1Dto.ChangePasswordRequest(VALID_PASSWORD, NEW_PASSWORD); + testRestTemplate.exchange(CHANGE_PASSWORD_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(changeRequest, changeHeaders), + new ParameterizedTypeReference>() {}); + + // μƒˆ λΉ„λ°€λ²ˆν˜Έλ‘œ λ‚΄ 정보 쑰회 + HttpHeaders meHeaders = new HttpHeaders(); + meHeaders.set(HEADER_LOGIN_ID, VALID_LOGIN_ID); + meHeaders.set(HEADER_LOGIN_PW, NEW_PASSWORD); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ME_ENDPOINT, HttpMethod.GET, new HttpEntity<>(meHeaders), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().loginId()).isEqualTo(VALID_LOGIN_ID) + ); + } + } } From fea8a99162113b982b74f562cec783a700be234b Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 15:19:40 +0900 Subject: [PATCH 10/20] =?UTF-8?q?refactor:=20Command=20=EA=B0=9D=EC=B2=B4?= =?UTF-8?q?=EB=A5=BC=20domain=EC=97=90=EC=84=9C=20application=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=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 --- .../user/ChangePasswordCommand.java | 7 +++ .../user/SignupCommand.java | 2 +- .../loopers/application/user/UserFacade.java | 13 +++-- .../domain/user/ChangePasswordCommand.java | 3 -- .../com/loopers/domain/user/UserService.java | 24 ++++----- .../interfaces/api/user/UserV1Controller.java | 12 +---- .../interfaces/api/user/UserV1Dto.java | 14 +++++- .../user/UserServiceIntegrationTest.java | 49 ++++++------------- .../loopers/domain/user/UserServiceTest.java | 39 +++++---------- 9 files changed, 67 insertions(+), 96 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/ChangePasswordCommand.java rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/user/SignupCommand.java (77%) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/ChangePasswordCommand.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/ChangePasswordCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/ChangePasswordCommand.java new file mode 100644 index 00000000..54aa936b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/ChangePasswordCommand.java @@ -0,0 +1,7 @@ +package com.loopers.application.user; + +public record ChangePasswordCommand( + String loginId, + String currentPassword, + String newPassword +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/SignupCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/SignupCommand.java similarity index 77% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/SignupCommand.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/SignupCommand.java index 722946a9..f751b360 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/SignupCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/SignupCommand.java @@ -1,4 +1,4 @@ -package com.loopers.domain.user; +package com.loopers.application.user; public record SignupCommand( String loginId, diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 9207b668..a738467b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -1,7 +1,5 @@ package com.loopers.application.user; -import com.loopers.domain.user.ChangePasswordCommand; -import com.loopers.domain.user.SignupCommand; import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserService; import lombok.RequiredArgsConstructor; @@ -12,8 +10,10 @@ public class UserFacade { private final UserService userService; - public UserInfo signUp(SignupCommand command){ - UserModel user = userService.signup(command); + public UserInfo signUp(SignupCommand command) { + UserModel user = userService.signup( + command.loginId(), command.password(), command.name(), command.birthday(), command.email() + ); return UserInfo.from(user); } @@ -22,8 +22,7 @@ public UserInfo getMyInfo(String loginId) { return UserInfo.from(user); } - public void changePassword(String loginId, String currentPassword, String newPassword) { - ChangePasswordCommand command = new ChangePasswordCommand(loginId, currentPassword, newPassword); - userService.changePassword(command); + public void changePassword(ChangePasswordCommand command) { + userService.changePassword(command.loginId(), command.currentPassword(), command.newPassword()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/ChangePasswordCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/ChangePasswordCommand.java deleted file mode 100644 index 92b86805..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/ChangePasswordCommand.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.loopers.domain.user; - -public record ChangePasswordCommand(String loginId, String currentPassword, String newPassword) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index d84e0fcd..57b046f2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -15,21 +15,21 @@ public class UserService { private final PasswordEncoder passwordEncoder; // νšŒμ›κ°€μž… - public UserModel signup(SignupCommand command){ + public UserModel signup(String loginId, String password, String name, String birthday, String email) { // 1. λΉ„λ°€λ²ˆν˜Έ 검증 (μ•”ν˜Έν™” μ „ raw password) - Password.validate(command.password(), command.birthday()); + Password.validate(password, birthday); // 2. 쀑볡 체크 - Optional existedUser = userRepository.findByLoginId(command.loginId()); - if(existedUser.isPresent()){ + Optional existedUser = userRepository.findByLoginId(loginId); + if (existedUser.isPresent()) { throw new CoreException(ErrorType.CONFLICT, "이미 μ‘΄μž¬ν•˜λŠ” 둜그인 IDμž…λ‹ˆλ‹€."); } // 3. λΉ„λ°€λ²ˆν˜Έ μ•”ν˜Έν™” - String encryptedPassword = passwordEncoder.encode(command.password()); + String encryptedPassword = passwordEncoder.encode(password); // 4. νšŒμ› 생성 및 μ €μž₯ - UserModel newUser = new UserModel(command.loginId(), encryptedPassword, command.name(), command.birthday(), command.email()); + UserModel newUser = new UserModel(loginId, encryptedPassword, name, birthday, email); return userRepository.save(newUser); } @@ -46,26 +46,26 @@ public UserModel authenticate(String loginId, String password) { } // λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ - public void changePassword(ChangePasswordCommand command) { + public void changePassword(String loginId, String currentPassword, String newPassword) { // 1. μ‚¬μš©μž 쑰회 - UserModel user = userRepository.findByLoginId(command.loginId()) + UserModel user = userRepository.findByLoginId(loginId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ‚¬μš©μžμž…λ‹ˆλ‹€.")); // 2. ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 확인 - if (!passwordEncoder.matches(command.currentPassword(), user.getPassword())) { + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { throw new CoreException(ErrorType.BAD_REQUEST, "ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); } // 3. μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•œμ§€ 확인 - if (passwordEncoder.matches(command.newPassword(), user.getPassword())) { + if (passwordEncoder.matches(newPassword, user.getPassword())) { throw new CoreException(ErrorType.BAD_REQUEST, "μƒˆ λΉ„λ°€λ²ˆν˜ΈλŠ” ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ 동일할 수 μ—†μŠ΅λ‹ˆλ‹€."); } // 4. λΉ„λ°€λ²ˆν˜Έ κ·œμΉ™ 검증 - Password.validate(command.newPassword(), user.getBirthday().toString()); + Password.validate(newPassword, user.getBirthday().toString()); // 5. μ•”ν˜Έν™” 및 μ €μž₯ - String encryptedPassword = passwordEncoder.encode(command.newPassword()); + String encryptedPassword = passwordEncoder.encode(newPassword); user.changePassword(encryptedPassword); userRepository.save(user); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index b6bcc4ac..b842d3f2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -3,7 +3,6 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; import com.loopers.config.AuthInterceptor; -import com.loopers.domain.user.SignupCommand; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -26,14 +25,7 @@ public class UserV1Controller implements UserV1ApiSpec { public ApiResponse signup( @RequestBody UserV1Dto.SignupRequest request ) { - SignupCommand command = new SignupCommand( - request.loginId(), - request.password(), - request.name(), - request.birthday(), - request.email() - ); - UserInfo info = userFacade.signUp(command); + UserInfo info = userFacade.signUp(request.toCommand()); UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); return ApiResponse.success(response); } @@ -54,7 +46,7 @@ public ApiResponse changePassword( @RequestAttribute(AuthInterceptor.ATTR_LOGIN_ID) String loginId, @RequestBody UserV1Dto.ChangePasswordRequest request ) { - userFacade.changePassword(loginId, request.currentPassword(), request.newPassword()); + userFacade.changePassword(request.toCommand(loginId)); return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index 31c33352..100edac8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -1,5 +1,7 @@ package com.loopers.interfaces.api.user; +import com.loopers.application.user.ChangePasswordCommand; +import com.loopers.application.user.SignupCommand; import com.loopers.application.user.UserInfo; import java.time.LocalDate; @@ -12,12 +14,20 @@ public record SignupRequest( String name, String birthday, String email - ) {} + ) { + public SignupCommand toCommand() { + return new SignupCommand(loginId, password, name, birthday, email); + } + } public record ChangePasswordRequest( String currentPassword, String newPassword - ) {} + ) { + public ChangePasswordCommand toCommand(String loginId) { + return new ChangePasswordCommand(loginId, currentPassword, newPassword); + } + } public record UserResponse( String loginId, diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index d9a355c3..cc402d54 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -44,16 +44,13 @@ class Signup { @DisplayName("정상적인 μ •λ³΄λ‘œ νšŒμ›κ°€μž…μ΄ μ„±κ³΅ν•œλ‹€.") @Test void signupSucceeds_whenInfoIsValid() { - // arrange - SignupCommand command = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - // act - UserModel result = userService.signup(command); + UserModel result = userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); // assert assertThat(result).isNotNull(); assertThat(result.getId()).isNotNull(); - assertThat(result.getLoginId()).isEqualTo(command.loginId()); + assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID); // DB에 μ‹€μ œλ‘œ μ €μž₯λλŠ”μ§€ 확인 assertThat(userJpaRepository.findById(result.getId())).isPresent(); @@ -66,12 +63,9 @@ void throwsException_whenLoginIdAlreadyExists() { UserModel existingUser = new UserModel(VALID_LOGIN_ID, "otherPw@123", "κΈ°μ‘΄νšŒμ›", "1990-01-01", "other@test.com"); userJpaRepository.save(existingUser); - // 같은 loginId둜 κ°€μž… μ‹œλ„ - SignupCommand command = new SignupCommand(VALID_LOGIN_ID, "newPw@1234", "μ‹ κ·œνšŒμ›", "1995-05-05", "new@test.com"); - // act & assert CoreException result = assertThrows(CoreException.class, () -> { - userService.signup(command); + userService.signup(VALID_LOGIN_ID, "newPw@1234", "μ‹ κ·œνšŒμ›", "1995-05-05", "new@test.com"); }); assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); @@ -86,8 +80,7 @@ class Authenticate { @Test void authenticateSucceeds_whenCredentialsAreValid() { // arrange - νšŒμ›κ°€μž… (BCrypt μ•”ν˜Έν™” 포함) - SignupCommand command = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - userService.signup(command); + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); // act UserModel result = userService.authenticate(VALID_LOGIN_ID, VALID_PASSWORD); @@ -112,8 +105,7 @@ void throwsException_whenLoginIdNotFound() { @Test void throwsException_whenPasswordDoesNotMatch() { // arrange - νšŒμ›κ°€μž… - SignupCommand command = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - userService.signup(command); + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); // act & assert CoreException result = assertThrows(CoreException.class, () -> { @@ -134,13 +126,10 @@ class ChangePassword { @Test void changePasswordSucceeds_whenInfoIsValid() { // arrange - νšŒμ›κ°€μž… - SignupCommand signupCommand = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - userService.signup(signupCommand); - - ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); // act - userService.changePassword(command); + userService.changePassword(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); // assert - μƒˆ λΉ„λ°€λ²ˆν˜Έλ‘œ 인증 성곡 UserModel result = userService.authenticate(VALID_LOGIN_ID, NEW_PASSWORD); @@ -152,11 +141,8 @@ void changePasswordSucceeds_whenInfoIsValid() { @Test void throwsException_whenAuthenticatingWithOldPassword() { // arrange - νšŒμ›κ°€μž… 및 λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ - SignupCommand signupCommand = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - userService.signup(signupCommand); - - ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); - userService.changePassword(command); + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + userService.changePassword(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); // act & assert - κΈ°μ‘΄ λΉ„λ°€λ²ˆν˜Έλ‘œ 인증 μ‹€νŒ¨ CoreException result = assertThrows(CoreException.class, () -> { @@ -170,14 +156,11 @@ void throwsException_whenAuthenticatingWithOldPassword() { @Test void throwsException_whenCurrentPasswordDoesNotMatch() { // arrange - νšŒμ›κ°€μž… - SignupCommand signupCommand = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - userService.signup(signupCommand); - - ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, "wrongPw@123", NEW_PASSWORD); + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); // act & assert CoreException result = assertThrows(CoreException.class, () -> { - userService.changePassword(command); + userService.changePassword(VALID_LOGIN_ID, "wrongPw@123", NEW_PASSWORD); }); assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); @@ -187,14 +170,11 @@ void throwsException_whenCurrentPasswordDoesNotMatch() { @Test void throwsException_whenNewPasswordIsSameAsCurrent() { // arrange - νšŒμ›κ°€μž… - SignupCommand signupCommand = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - userService.signup(signupCommand); - - ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_PASSWORD); + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); // act & assert CoreException result = assertThrows(CoreException.class, () -> { - userService.changePassword(command); + userService.changePassword(VALID_LOGIN_ID, VALID_PASSWORD, VALID_PASSWORD); }); assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); @@ -209,8 +189,7 @@ class FindByLoginId { @Test void returnsUser_whenLoginIdExists() { // arrange - νšŒμ›κ°€μž… - SignupCommand command = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - userService.signup(command); + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); // act UserModel result = userService.findByLoginId(VALID_LOGIN_ID); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index 8fa3062c..ab962e61 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -45,14 +45,12 @@ class Signup { @Test void signupSucceeds_whenInfoIsValid() { // arrange - SignupCommand command = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - // stub: findByLoginId ν˜ΈμΆœν•˜λ©΄ 빈 κ°’ λ°˜ν™˜ (ν•΄λ‹Ή μ•„μ΄λ””λ‘œ κ°€μž…λœ νšŒμ› μ—†μŒ) - when(userRepository.findByLoginId(command.loginId())) + when(userRepository.findByLoginId(VALID_LOGIN_ID)) .thenReturn(Optional.empty()); // stub: λΉ„λ°€λ²ˆν˜Έ μ•”ν˜Έν™” - when(passwordEncoder.encode(command.password())) + when(passwordEncoder.encode(VALID_PASSWORD)) .thenReturn("encrypted_password"); // stub: save 호좜 μ‹œ μ €μž₯된 객체 λ°˜ν™˜ @@ -60,11 +58,11 @@ void signupSucceeds_whenInfoIsValid() { .thenAnswer((invocation) -> invocation.getArgument(0)); // act - UserModel result = userService.signup(command); + UserModel result = userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); // assert assertThat(result).isNotNull(); - assertThat(result.getLoginId()).isEqualTo(command.loginId()); + assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID); verify(userRepository, times(1)).save(any(UserModel.class)); } @@ -73,18 +71,16 @@ void signupSucceeds_whenInfoIsValid() { @Test void throwsException_whenLoginIdAlreadyExists() { // arrange - SignupCommand command = new SignupCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - // 이미 μ‘΄μž¬ν•˜λŠ” νšŒμ› 생성 - UserModel existingUser = new UserModel(command.loginId(), "anonymous@123", "κΈ°μ‘΄νšŒμ›", "1990-01-01", "anonymous@gmail.com"); + UserModel existingUser = new UserModel(VALID_LOGIN_ID, "anonymous@123", "κΈ°μ‘΄νšŒμ›", "1990-01-01", "anonymous@gmail.com"); // stub: IDκ°€ μ€‘λ³΅λ˜λŠ” 이미 μ‘΄μž¬ν•˜λŠ” UserModel객체 λ°˜ν™˜ - when(userRepository.findByLoginId(command.loginId())) + when(userRepository.findByLoginId(VALID_LOGIN_ID)) .thenReturn(Optional.of(existingUser)); // act CoreException result = assertThrows(CoreException.class, () -> { - userService.signup(command); + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); }); // assert @@ -166,7 +162,6 @@ class ChangePassword { @Test void changePasswordSucceeds_whenInfoIsValid() { // arrange - ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); UserModel user = new UserModel(VALID_LOGIN_ID, VALID_ENCRYPTED_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); when(userRepository.findByLoginId(VALID_LOGIN_ID)) @@ -181,7 +176,7 @@ void changePasswordSucceeds_whenInfoIsValid() { .thenAnswer(invocation -> invocation.getArgument(0)); // act - userService.changePassword(command); + userService.changePassword(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); // assert verify(userRepository, times(1)).save(any(UserModel.class)); @@ -191,14 +186,12 @@ void changePasswordSucceeds_whenInfoIsValid() { @Test void throwsException_whenUserNotFound() { // arrange - ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); - when(userRepository.findByLoginId(VALID_LOGIN_ID)) .thenReturn(Optional.empty()); // act CoreException result = assertThrows(CoreException.class, () -> { - userService.changePassword(command); + userService.changePassword(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); }); // assert @@ -210,7 +203,6 @@ void throwsException_whenUserNotFound() { @Test void throwsException_whenCurrentPasswordDoesNotMatch() { // arrange - ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, "wrongPw@123", NEW_PASSWORD); UserModel user = new UserModel(VALID_LOGIN_ID, VALID_ENCRYPTED_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); when(userRepository.findByLoginId(VALID_LOGIN_ID)) @@ -220,7 +212,7 @@ void throwsException_whenCurrentPasswordDoesNotMatch() { // act CoreException result = assertThrows(CoreException.class, () -> { - userService.changePassword(command); + userService.changePassword(VALID_LOGIN_ID, "wrongPw@123", NEW_PASSWORD); }); // assert @@ -232,20 +224,16 @@ void throwsException_whenCurrentPasswordDoesNotMatch() { @Test void throwsException_whenNewPasswordIsSameAsCurrent() { // arrange - ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, VALID_PASSWORD); UserModel user = new UserModel(VALID_LOGIN_ID, VALID_ENCRYPTED_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); when(userRepository.findByLoginId(VALID_LOGIN_ID)) .thenReturn(Optional.of(user)); when(passwordEncoder.matches(VALID_PASSWORD, VALID_ENCRYPTED_PASSWORD)) .thenReturn(true); - // μƒˆ λΉ„λ°€λ²ˆν˜Έλ„ ν˜„μž¬μ™€ 동일 - when(passwordEncoder.matches(VALID_PASSWORD, VALID_ENCRYPTED_PASSWORD)) - .thenReturn(true); // act CoreException result = assertThrows(CoreException.class, () -> { - userService.changePassword(command); + userService.changePassword(VALID_LOGIN_ID, VALID_PASSWORD, VALID_PASSWORD); }); // assert @@ -256,8 +244,7 @@ void throwsException_whenNewPasswordIsSameAsCurrent() { @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ κ·œμΉ™μ— μœ„λ°˜λ˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") @Test void throwsException_whenNewPasswordViolatesRules() { - // arrange - λ„ˆλ¬΄ 짧은 λΉ„λ°€λ²ˆν˜Έ - ChangePasswordCommand command = new ChangePasswordCommand(VALID_LOGIN_ID, VALID_PASSWORD, "short"); + // arrange UserModel user = new UserModel(VALID_LOGIN_ID, VALID_ENCRYPTED_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); when(userRepository.findByLoginId(VALID_LOGIN_ID)) @@ -269,7 +256,7 @@ void throwsException_whenNewPasswordViolatesRules() { // act CoreException result = assertThrows(CoreException.class, () -> { - userService.changePassword(command); + userService.changePassword(VALID_LOGIN_ID, VALID_PASSWORD, "short"); }); // assert From a61624a42db80815679d366f84a9cb694905b45a Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 16:25:36 +0900 Subject: [PATCH 11/20] =?UTF-8?q?refactor:=20UserService=EC=97=90=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/user/UserService.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 57b046f2..424745ad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @@ -15,6 +16,7 @@ public class UserService { private final PasswordEncoder passwordEncoder; // νšŒμ›κ°€μž… + @Transactional public UserModel signup(String loginId, String password, String name, String birthday, String email) { // 1. λΉ„λ°€λ²ˆν˜Έ 검증 (μ•”ν˜Έν™” μ „ raw password) Password.validate(password, birthday); @@ -34,6 +36,7 @@ public UserModel signup(String loginId, String password, String name, String bir } // 인증 + @Transactional(readOnly = true) public UserModel authenticate(String loginId, String password) { UserModel user = userRepository.findByLoginId(loginId) .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "νšŒμ› 정보가 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")); @@ -46,6 +49,7 @@ public UserModel authenticate(String loginId, String password) { } // λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ + @Transactional public void changePassword(String loginId, String currentPassword, String newPassword) { // 1. μ‚¬μš©μž 쑰회 UserModel user = userRepository.findByLoginId(loginId) @@ -71,6 +75,7 @@ public void changePassword(String loginId, String currentPassword, String newPas } // 둜그인 ID둜 쑰회 + @Transactional(readOnly = true) public UserModel findByLoginId(String loginId) { return userRepository.findByLoginId(loginId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ‚¬μš©μžμž…λ‹ˆλ‹€.")); From 57814f366ca838d7e228564f13bd98e2df2bd388 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 16:28:10 +0900 Subject: [PATCH 12/20] =?UTF-8?q?fix:=20UserService=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=ED=83=80=EC=9E=85=20=EB=B6=88=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/user/UserServiceIntegrationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index cc402d54..7fd2d1c0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -68,7 +68,7 @@ void throwsException_whenLoginIdAlreadyExists() { userService.signup(VALID_LOGIN_ID, "newPw@1234", "μ‹ κ·œνšŒμ›", "1995-05-05", "new@test.com"); }); - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); } } From 33a5b1326f905a371df36287258675535f62c50f Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 16:30:23 +0900 Subject: [PATCH 13/20] =?UTF-8?q?fix:=20UserModel=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=EB=90=9C=20?= =?UTF-8?q?=EB=AF=B8=EB=9E=98=20=EB=82=A0=EC=A7=9C=EB=A5=BC=20=ED=95=AD?= =?UTF-8?q?=EC=83=81=20=ED=98=84=EC=9E=AC=20=EB=82=A0=EC=A7=9C=EC=9D=98=20?= =?UTF-8?q?1=EB=85=84=20=EB=92=A4=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/test/java/com/loopers/domain/user/UserModelTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 2fc80b8c..85fb988c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -6,6 +6,8 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.time.LocalDate; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -16,6 +18,7 @@ class UserModelTest { private static final String VALID_NAME = "namjin"; private static final String VALID_BIRTHDAY = "1994-05-25"; private static final String VALID_EMAIL = "epemxksl@gmail.com"; + private static final String FUTURE_BIRTHDAY = LocalDate.now().plusYears(1).toString(); @DisplayName("νšŒμ›μ„ 생성할 λ•Œ, ") @Nested @@ -96,7 +99,7 @@ void throwsBadRequestException_whenBirthDateFormatIsInvalid() { void throwsBadRequestException_whenBirthDateisAfterToday(){ // act CoreException result = assertThrows(CoreException.class, () -> { - new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, "2027-01-01", VALID_EMAIL); + new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, FUTURE_BIRTHDAY, VALID_EMAIL); }); // assert From 9587b680a0fd92819a8ad7ffadebb00cc346f65b Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 16:33:44 +0900 Subject: [PATCH 14/20] =?UTF-8?q?fix:=20UserModel=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=83=9D=EB=85=84=EC=9B=94=EC=9D=BC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/user/UserModelTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 85fb988c..dc497824 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -105,6 +105,31 @@ void throwsBadRequestException_whenBirthDateisAfterToday(){ // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } + + @DisplayName("생년월일이 λΉ„μ–΄μžˆμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenBirthdayIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, "", VALID_EMAIL); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일이 null이면, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenBirthdayIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, null, VALID_EMAIL); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } @DisplayName("λΉ„λ°€λ²ˆν˜Έλ₯Ό λ³€κ²½ν•  λ•Œ, ") From 5d5066c98cca06cefb914d05ee192db8e792a3b9 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 18:45:31 +0900 Subject: [PATCH 15/20] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20ID=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EB=A1=9C=EC=A7=81=EC=9D=84=20findByLoginId=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EB=A5=BC=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=ED=95=B4=EC=84=9C=20=EA=B0=9D=EC=B2=B4=EB=A5=BC=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=EB=B0=9B=EC=95=84=EC=84=9C=20=ED=95=98=EB=8A=94?= =?UTF-8?q?=EA=B2=8C=20=EC=95=84=EB=8B=8C=20existsByLoginId=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EB=A1=9C=20=EC=A4=91=EB=B3=B5=EB=90=9C=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EA=B0=80=20=EC=A1=B4=EC=9E=AC?= =?UTF-8?q?=ED=95=98=EB=8A=94=EC=A7=80=20=EC=97=AC=EB=B6=80=EB=A7=8C=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/user/UserRepository.java | 1 + .../java/com/loopers/domain/user/UserService.java | 3 +-- .../infrastructure/user/UserJpaRepository.java | 1 + .../infrastructure/user/UserRepositoryImpl.java | 8 +++++++- .../com/loopers/domain/user/UserServiceTest.java | 15 ++++++--------- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index 03e9ca8e..eadc5d43 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -4,5 +4,6 @@ public interface UserRepository { Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); UserModel save(UserModel user); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 424745ad..89e594b2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -22,8 +22,7 @@ public UserModel signup(String loginId, String password, String name, String bir Password.validate(password, birthday); // 2. 쀑볡 체크 - Optional existedUser = userRepository.findByLoginId(loginId); - if (existedUser.isPresent()) { + if (userRepository.existsByLoginId(loginId)) { throw new CoreException(ErrorType.CONFLICT, "이미 μ‘΄μž¬ν•˜λŠ” 둜그인 IDμž…λ‹ˆλ‹€."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index d754c3d6..f94d1f75 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -7,4 +7,5 @@ public interface UserJpaRepository extends JpaRepository { Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 63a3fa52..135a1300 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -4,11 +4,12 @@ import com.loopers.domain.user.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.stereotype.Repository; import java.util.Optional; @RequiredArgsConstructor -@Component +@Repository public class UserRepositoryImpl implements UserRepository { private final UserJpaRepository userJpaRepository; @@ -17,6 +18,11 @@ public Optional findByLoginId(String loginId) { return userJpaRepository.findByLoginId(loginId); } + @Override + public boolean existsByLoginId(String loginId) { + return userJpaRepository.existsByLoginId(loginId); + } + @Override public UserModel save(UserModel user) { return userJpaRepository.save(user); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index ab962e61..e583eccb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -45,9 +45,9 @@ class Signup { @Test void signupSucceeds_whenInfoIsValid() { // arrange - // stub: findByLoginId ν˜ΈμΆœν•˜λ©΄ 빈 κ°’ λ°˜ν™˜ (ν•΄λ‹Ή μ•„μ΄λ””λ‘œ κ°€μž…λœ νšŒμ› μ—†μŒ) - when(userRepository.findByLoginId(VALID_LOGIN_ID)) - .thenReturn(Optional.empty()); + // stub: existsByLoginId ν˜ΈμΆœν•˜λ©΄ false λ°˜ν™˜ (ν•΄λ‹Ή μ•„μ΄λ””λ‘œ κ°€μž…λœ νšŒμ› μ—†μŒ) + when(userRepository.existsByLoginId(VALID_LOGIN_ID)) + .thenReturn(false); // stub: λΉ„λ°€λ²ˆν˜Έ μ•”ν˜Έν™” when(passwordEncoder.encode(VALID_PASSWORD)) @@ -71,12 +71,9 @@ void signupSucceeds_whenInfoIsValid() { @Test void throwsException_whenLoginIdAlreadyExists() { // arrange - // 이미 μ‘΄μž¬ν•˜λŠ” νšŒμ› 생성 - UserModel existingUser = new UserModel(VALID_LOGIN_ID, "anonymous@123", "κΈ°μ‘΄νšŒμ›", "1990-01-01", "anonymous@gmail.com"); - - // stub: IDκ°€ μ€‘λ³΅λ˜λŠ” 이미 μ‘΄μž¬ν•˜λŠ” UserModel객체 λ°˜ν™˜ - when(userRepository.findByLoginId(VALID_LOGIN_ID)) - .thenReturn(Optional.of(existingUser)); + // stub: existsByLoginId ν˜ΈμΆœν•˜λ©΄ true λ°˜ν™˜ (이미 μ‘΄μž¬ν•˜λŠ” νšŒμ›) + when(userRepository.existsByLoginId(VALID_LOGIN_ID)) + .thenReturn(true); // act CoreException result = assertThrows(CoreException.class, () -> { From 7e69f0cab31cdb7571ce74c93485aaec2cfaab01 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 18:47:49 +0900 Subject: [PATCH 16/20] =?UTF-8?q?refactor:=20null=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/application/user/UserInfo.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java index 093ef929..5539a341 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -3,6 +3,7 @@ import com.loopers.domain.user.UserModel; import java.time.LocalDate; +import java.util.Objects; public record UserInfo( Long id, @@ -12,6 +13,7 @@ public record UserInfo( String email ) { public static UserInfo from(UserModel userModel) { + Objects.requireNonNull(userModel, "userModel은 null일 수 μ—†μŠ΅λ‹ˆλ‹€."); return new UserInfo( userModel.getId(), userModel.getLoginId(), From c4d735499d243ce2a53ac980ae9cf1cbf2f861a2 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 19:01:02 +0900 Subject: [PATCH 17/20] =?UTF-8?q?refactor:=20AuthInterceptor=20=EB=A1=9C?= =?UTF-8?q?=EA=B9=85=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/config/AuthInterceptor.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/config/AuthInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/config/AuthInterceptor.java index da4934ac..8cec0fc8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/AuthInterceptor.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/AuthInterceptor.java @@ -9,9 +9,13 @@ import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + @RequiredArgsConstructor @Component public class AuthInterceptor implements HandlerInterceptor { + private static final Logger log = LoggerFactory.getLogger(AuthInterceptor.class); private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; @@ -25,10 +29,16 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons String loginPw = request.getHeader(HEADER_LOGIN_PW); if (loginId == null || loginId.isBlank() || loginPw == null || loginPw.isBlank()) { + log.warn("인증 헀더 λˆ„λ½ - URI: {}, RemoteAddr: {}", request.getRequestURI(), request.getRemoteAddr()); throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헀더가 λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); } - userService.authenticate(loginId, loginPw); + try{ + userService.authenticate(loginId, loginPw); + }catch(CoreException e){ + log.warn("인증 μ‹€νŒ¨ - loginId: {}, URI: {}", loginId, request.getRequestURI()); + throw e; + } request.setAttribute(ATTR_LOGIN_ID, loginId); return true; From 07b832b3d98f4abfd3fcbf823da4d1678ab68081 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 19:34:05 +0900 Subject: [PATCH 18/20] =?UTF-8?q?refactor:=20UserServiceTest=20=EA=B0=80?= =?UTF-8?q?=EB=8F=85=EC=84=B1=20=EA=B0=95=ED=99=94.=20=EC=83=88=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=EA=B0=80=20=ED=98=84?= =?UTF-8?q?=EC=9E=AC=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=EC=99=80=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=ED=95=B4?= =?UTF-8?q?=EC=84=9C=20stub=EC=9D=84=20=ED=95=9C=20=EB=B2=88=EB=A7=8C=20?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=9E=88=EC=9C=BC=EB=AF=80=EB=A1=9C=20?= =?UTF-8?q?=ED=98=BC=EB=9E=80=EC=9D=84=20=EC=A4=84=20=EC=88=98=EA=B0=80=20?= =?UTF-8?q?=EC=9E=88=EC=9D=8C.=20=EC=9D=B4=ED=95=B4=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/test/java/com/loopers/domain/user/UserServiceTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index e583eccb..1f8f0798 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -71,7 +71,6 @@ void signupSucceeds_whenInfoIsValid() { @Test void throwsException_whenLoginIdAlreadyExists() { // arrange - // stub: existsByLoginId ν˜ΈμΆœν•˜λ©΄ true λ°˜ν™˜ (이미 μ‘΄μž¬ν•˜λŠ” νšŒμ›) when(userRepository.existsByLoginId(VALID_LOGIN_ID)) .thenReturn(true); @@ -225,6 +224,8 @@ void throwsException_whenNewPasswordIsSameAsCurrent() { when(userRepository.findByLoginId(VALID_LOGIN_ID)) .thenReturn(Optional.of(user)); + // currentPassword == newPassword == VALID_PASSWORD μ΄λ―€λ‘œ, + // 1μ°¨ 호좜(ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 확인)κ³Ό 2μ°¨ 호좜(μƒˆ λΉ„λ°€λ²ˆν˜Έ == ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 확인) λͺ¨λ‘ 이 stub에 λ§€μΉ­λœλ‹€ when(passwordEncoder.matches(VALID_PASSWORD, VALID_ENCRYPTED_PASSWORD)) .thenReturn(true); From eac2aad433f7246ef461448e3857092bba93a3e0 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 19:34:43 +0900 Subject: [PATCH 19/20] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=EC=85=89=ED=84=B0=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=ED=97=A4=EB=8D=94=EC=9D=98=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20Id=EC=A0=95=EB=B3=B4=EA=B0=80=20=EB=B9=88?= =?UTF-8?q?=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EC=98=A4=EB=8A=94=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=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 --- .../loopers/config/AuthInterceptorTest.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/config/AuthInterceptorTest.java b/apps/commerce-api/src/test/java/com/loopers/config/AuthInterceptorTest.java index 7c88566f..24493123 100644 --- a/apps/commerce-api/src/test/java/com/loopers/config/AuthInterceptorTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/config/AuthInterceptorTest.java @@ -61,6 +61,9 @@ void throwsException_whenHeadersMissing() { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); + when(request.getRequestURI()).thenReturn("/api/v1/users/me"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + // act & assert CoreException result = assertThrows(CoreException.class, () -> { authInterceptor.preHandle(request, response, new Object()); @@ -79,6 +82,7 @@ void throwsException_whenAuthenticationFails() { when(request.getHeader(HEADER_LOGIN_ID)).thenReturn(VALID_LOGIN_ID); when(request.getHeader(HEADER_LOGIN_PW)).thenReturn("wrongPassword"); + when(request.getRequestURI()).thenReturn("/api/v1/users/me"); when(userService.authenticate(VALID_LOGIN_ID, "wrongPassword")) .thenThrow(new CoreException(ErrorType.UNAUTHORIZED, "νšŒμ› 정보가 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")); @@ -90,5 +94,24 @@ void throwsException_whenAuthenticationFails() { assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); } + + @DisplayName("빈 λ¬Έμžμ—΄ 인증 ν—€λ”λ‘œ μš”μ²­ν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenHeadersAreBlank() { + // arrange + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + + when(request.getHeader(HEADER_LOGIN_ID)).thenReturn(" "); + when(request.getHeader(HEADER_LOGIN_PW)).thenReturn(""); + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + authInterceptor.preHandle(request, response, new Object()); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + verify(userService, never()).authenticate(any(), any()); + } } } From c5854c75e4d9aa92622f0e49ad2f1d255a1247af Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 6 Feb 2026 19:43:25 +0900 Subject: [PATCH 20/20] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EB=88=84=EB=9D=BD=20E2E=20test=EC=97=90=EC=84=9C?= =?UTF-8?q?=20HttpEntity=20=EC=A0=84=EB=8B=AC=20=EC=8B=9C=20null=20?= =?UTF-8?q?=EC=95=84=EB=8B=8C=20=EB=B9=88=20HttpEntity=EB=A5=BC=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC.=20httpEntity=EB=A1=9C=20null=EC=9D=84=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=ED=95=98=EB=A9=B4=20=EB=B3=B8=EB=AC=B8=20=EB=B0=8F=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EB=91=98=20=EB=8B=A4=20=EC=97=86=EB=8B=A4?= =?UTF-8?q?=EB=8A=94=20=EC=9D=98=EB=AF=B8=EC=9D=B4=EA=B8=B0=20=EB=95=8C?= =?UTF-8?q?=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/interfaces/api/UserV1ApiE2ETest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index d8af0c83..69af5241 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api; import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -101,7 +102,7 @@ void throwsBadRequest_whenLoginIdAlreadyExists() { // assert assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) ); } @@ -147,7 +148,8 @@ void returnsUnauthorized_whenHeadersMissing() { // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(ME_ENDPOINT, HttpMethod.GET, null, responseType); + // httpEntity둜 null을 μ „λ‹¬ν•˜λ©΄ λ³Έλ¬Έ 및 헀더 λ‘˜ λ‹€ μ—†λ‹€λŠ” 의미이기 λ•Œλ¬Έμ— 빈 HttpEntityλ₯Ό μ „λ‹¬ν•˜μž. + testRestTemplate.exchange(ME_ENDPOINT, HttpMethod.GET, HttpEntity.EMPTY, responseType); // assert assertAll(