diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e..00000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## πŸ§ͺ Implementation Quest - -> μ§€μ •λœ **λ‹¨μœ„ ν…ŒμŠ€νŠΈ / 톡합 ν…ŒμŠ€νŠΈ / E2E ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€**λ₯Ό ν•„μˆ˜λ‘œ κ΅¬ν˜„ν•˜κ³ , λͺ¨λ“  ν…ŒμŠ€νŠΈλ₯Ό ν†΅κ³Όμ‹œν‚€λŠ” 것을 λͺ©ν‘œλ‘œ ν•©λ‹ˆλ‹€. - -### νšŒμ› κ°€μž… - -**🧱 λ‹¨μœ„ ν…ŒμŠ€νŠΈ** - -- [ ] ID κ°€ `영문 및 숫자 10자 이내` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. -- [ ] 이메일이 `xx@yy.zz` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. -- [ ] 생년월일이 `yyyy-MM-dd` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] νšŒμ› κ°€μž…μ‹œ User μ €μž₯이 μˆ˜ν–‰λœλ‹€. ( spy 검증 ) -- [ ] 이미 κ°€μž…λœ ID 둜 νšŒμ›κ°€μž… μ‹œλ„ μ‹œ, μ‹€νŒ¨ν•œλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] νšŒμ› κ°€μž…μ΄ 성곡할 경우, μƒμ„±λœ μœ μ € 정보λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] νšŒμ› κ°€μž… μ‹œμ— 성별이 없을 경우, `400 Bad Request` 응닡을 λ°˜ν™˜ν•œλ‹€. - -### λ‚΄ 정보 쑰회 - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•  경우, νšŒμ› 정보가 λ°˜ν™˜λœλ‹€. -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ 경우, null 이 λ°˜ν™˜λœλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] λ‚΄ 정보 μ‘°νšŒμ— 성곡할 경우, ν•΄λ‹Ήν•˜λŠ” μœ μ € 정보λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ID 둜 μ‘°νšŒν•  경우, `404 Not Found` 응닡을 λ°˜ν™˜ν•œλ‹€. - -### 포인트 쑰회 - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•  경우, 보유 ν¬μΈνŠΈκ°€ λ°˜ν™˜λœλ‹€. -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ 경우, null 이 λ°˜ν™˜λœλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] 포인트 μ‘°νšŒμ— 성곡할 경우, 보유 포인트λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] `X-USER-ID` 헀더가 없을 경우, `400 Bad Request` 응닡을 λ°˜ν™˜ν•œλ‹€. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d2b946e8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,144 @@ +# 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`) +- ν…ŒμŠ€νŠΈ 데이터 쀑 μ—¬λŸ¬ ν…ŒμŠ€νŠΈμ—μ„œ 반볡 μ‚¬μš©λ˜λŠ” 값은 클래슀 레벨 μƒμˆ˜(`private static final`)둜 μ„ μ–Έν•œλ‹€ + +### μ½”λ“œ μŠ€νƒ€μΌ +- 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 브둜컀 | 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/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/application/user/SignupCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/SignupCommand.java new file mode 100644 index 00000000..f751b360 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/SignupCommand.java @@ -0,0 +1,9 @@ +package com.loopers.application.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/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 00000000..a738467b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,28 @@ +package com.loopers.application.user; + +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.loginId(), command.password(), command.name(), command.birthday(), command.email() + ); + return UserInfo.from(user); + } + + public UserInfo getMyInfo(String loginId) { + UserModel user = userService.findByLoginId(loginId); + return UserInfo.from(user); + } + + public void changePassword(ChangePasswordCommand command) { + userService.changePassword(command.loginId(), command.currentPassword(), command.newPassword()); + } +} 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..5539a341 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,25 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.UserModel; + +import java.time.LocalDate; +import java.util.Objects; + +public record UserInfo( + Long id, + String loginId, + String name, + LocalDate birthday, + String email +) { + public static UserInfo from(UserModel userModel) { + Objects.requireNonNull(userModel, "userModel은 null일 수 μ—†μŠ΅λ‹ˆλ‹€."); + return new UserInfo( + userModel.getId(), + userModel.getLoginId(), + userModel.getName(), + userModel.getBirthday(), + userModel.getEmail() + ); + } +} 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..8cec0fc8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/AuthInterceptor.java @@ -0,0 +1,46 @@ +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; + +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"; + 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()) { + log.warn("인증 헀더 λˆ„λ½ - URI: {}, RemoteAddr: {}", request.getRequestURI(), request.getRemoteAddr()); + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헀더가 λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + 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; + } +} 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/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/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/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java new file mode 100644 index 00000000..7c7b0e3d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -0,0 +1,102 @@ +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.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; + +@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; + + 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(!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(!email.matches(EMAIL_PATTERN)){ + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + } + + private void validatePassword(String password){ + if(password == null || password.isBlank()){ + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + } + + 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, "생년월일은 λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + LocalDate parsed; + try{ + parsed = LocalDate.parse(birthday); + }catch(DateTimeParseException e){ + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + if(parsed.isAfter(LocalDate.now())){ + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래 λ‚ μ§œμΌ 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + return parsed; + } +} 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..eadc5d43 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +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 new file mode 100644 index 00000000..89e594b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,82 @@ +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 org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserService { + private final UserRepository userRepository; + 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); + + // 2. 쀑볡 체크 + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 μ‘΄μž¬ν•˜λŠ” 둜그인 IDμž…λ‹ˆλ‹€."); + } + + // 3. λΉ„λ°€λ²ˆν˜Έ μ•”ν˜Έν™” + String encryptedPassword = passwordEncoder.encode(password); + + // 4. νšŒμ› 생성 및 μ €μž₯ + UserModel newUser = new UserModel(loginId, encryptedPassword, name, birthday, email); + return userRepository.save(newUser); + } + + // 인증 + @Transactional(readOnly = true) + 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; + } + + // λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ + @Transactional + public void changePassword(String loginId, String currentPassword, String newPassword) { + // 1. μ‚¬μš©μž 쑰회 + UserModel user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ‚¬μš©μžμž…λ‹ˆλ‹€.")); + + // 2. ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 확인 + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + // 3. μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•œμ§€ 확인 + if (passwordEncoder.matches(newPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "μƒˆ λΉ„λ°€λ²ˆν˜ΈλŠ” ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ 동일할 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + // 4. λΉ„λ°€λ²ˆν˜Έ κ·œμΉ™ 검증 + Password.validate(newPassword, user.getBirthday().toString()); + + // 5. μ•”ν˜Έν™” 및 μ €μž₯ + String encryptedPassword = passwordEncoder.encode(newPassword); + user.changePassword(encryptedPassword); + userRepository.save(user); + } + + // 둜그인 ID둜 쑰회 + @Transactional(readOnly = true) + 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/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 00000000..f94d1f75 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,11 @@ +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); + 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 new file mode 100644 index 00000000..135a1300 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,30 @@ +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 org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class UserRepositoryImpl implements UserRepository { + private final UserJpaRepository userJpaRepository; + + @Override + 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/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..6c32ff1c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,27 @@ +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); + + @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 new file mode 100644 index 00000000..b842d3f2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.config.AuthInterceptor; +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; + +@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 + ) { + UserInfo info = userFacade.signUp(request.toCommand()); + 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(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 new file mode 100644 index 00000000..100edac8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,54 @@ +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; + +public class UserV1Dto { + + public record SignupRequest( + String loginId, + String password, + 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, + String name, + LocalDate birthday, + String email + ) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.loginId(), + 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/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..24493123 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/config/AuthInterceptorTest.java @@ -0,0 +1,117 @@ +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); + + 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()); + }); + + 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(request.getRequestURI()).thenReturn("/api/v1/users/me"); + + 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); + } + + @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()); + } + } +} 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 new file mode 100644 index 00000000..dc497824 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -0,0 +1,183 @@ +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 java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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"; + private static final String FUTURE_BIRTHDAY = LocalDate.now().plusYears(1).toString(); + + @DisplayName("νšŒμ›μ„ 생성할 λ•Œ, ") + @Nested + class Create { + + @DisplayName("λͺ¨λ“  정보가 μ˜¬λ°”λ₯΄λ©΄, μ •μƒμ μœΌλ‘œ μƒμ„±λœλ‹€.") + @Test + void createsUser_whenAllInfoIsProvided() { + // act + UserModel user = new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // assert + assertThat(user).isNotNull(); + } + + @DisplayName("둜그인 IDκ°€ λΉ„μ–΄μžˆμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenLoginIdIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel("", VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("둜그인 ID에 영문과 숫자 μ™Έ λ¬Έμžκ°€ ν¬ν•¨λ˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenLoginIdContainsInvalidCharacters() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel("namjin123!@#", VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 λΉ„μ–΄μžˆμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenNameIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, "", VALID_BIRTHDAY, VALID_EMAIL); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이메일 포맷이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenEmailFormatIsInvalid() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, "epemxksl@gmail-com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일 포맷이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequestException_whenBirthDateFormatIsInvalid() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, "1994-005-25", VALID_EMAIL); + }); + + // 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, FUTURE_BIRTHDAY, VALID_EMAIL); + }); + + // 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("λΉ„λ°€λ²ˆν˜Έλ₯Ό λ³€κ²½ν•  λ•Œ, ") + @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 new file mode 100644 index 00000000..7fd2d1c0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,213 @@ +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() { + // act + 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(VALID_LOGIN_ID); + + // 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); + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + userService.signup(VALID_LOGIN_ID, "newPw@1234", "μ‹ κ·œνšŒμ›", "1995-05-05", "new@test.com"); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("인증을 ν•  λ•Œ, ") + @Nested + class Authenticate { + + @DisplayName("μ˜¬λ°”λ₯Έ 자격증λͺ…μœΌλ‘œ 인증이 μ„±κ³΅ν•œλ‹€.") + @Test + void authenticateSucceeds_whenCredentialsAreValid() { + // arrange - νšŒμ›κ°€μž… (BCrypt μ•”ν˜Έν™” 포함) + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // 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 - νšŒμ›κ°€μž… + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // 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 - νšŒμ›κ°€μž… + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // act + userService.changePassword(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); + + // 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 - νšŒμ›κ°€μž… 및 λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ + 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, () -> { + userService.authenticate(VALID_LOGIN_ID, VALID_PASSWORD); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenCurrentPasswordDoesNotMatch() { + // arrange - νšŒμ›κ°€μž… + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(VALID_LOGIN_ID, "wrongPw@123", NEW_PASSWORD); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenNewPasswordIsSameAsCurrent() { + // arrange - νšŒμ›κ°€μž… + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(VALID_LOGIN_ID, VALID_PASSWORD, VALID_PASSWORD); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("둜그인 ID둜 μ‘°νšŒν•  λ•Œ, ") + @Nested + class FindByLoginId { + + @DisplayName("μ‘΄μž¬ν•˜λŠ” 둜그인 ID둜 μ‘°νšŒν•˜λ©΄, μ‚¬μš©μžλ₯Ό λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsUser_whenLoginIdExists() { + // arrange - νšŒμ›κ°€μž… + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // 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 new file mode 100644 index 00000000..1f8f0798 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -0,0 +1,303 @@ +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_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"; + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private UserService userService; + + @DisplayName("νšŒμ›κ°€μž…μ„ ν•  λ•Œ, ") + @Nested + class Signup { + + @DisplayName("정상적인 μ •λ³΄λ‘œ νšŒμ›κ°€μž…μ΄ μ„±κ³΅ν•œλ‹€.") + @Test + void signupSucceeds_whenInfoIsValid() { + // arrange + // stub: existsByLoginId ν˜ΈμΆœν•˜λ©΄ false λ°˜ν™˜ (ν•΄λ‹Ή μ•„μ΄λ””λ‘œ κ°€μž…λœ νšŒμ› μ—†μŒ) + when(userRepository.existsByLoginId(VALID_LOGIN_ID)) + .thenReturn(false); + + // stub: λΉ„λ°€λ²ˆν˜Έ μ•”ν˜Έν™” + when(passwordEncoder.encode(VALID_PASSWORD)) + .thenReturn("encrypted_password"); + + // stub: save 호좜 μ‹œ μ €μž₯된 객체 λ°˜ν™˜ + when(userRepository.save(any(UserModel.class))) + .thenAnswer((invocation) -> invocation.getArgument(0)); + + // act + UserModel result = userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID); + + verify(userRepository, times(1)).save(any(UserModel.class)); + } + + @DisplayName("이미 κ°€μž…λœ 둜그인 ID둜 κ°€μž…ν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenLoginIdAlreadyExists() { + // arrange + when(userRepository.existsByLoginId(VALID_LOGIN_ID)) + .thenReturn(true); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.signup(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + }); + + // assert + 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 + 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(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); + + // assert + verify(userRepository, times(1)).save(any(UserModel.class)); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ‚¬μš©μžμ΄λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenUserNotFound() { + // arrange + when(userRepository.findByLoginId(VALID_LOGIN_ID)) + .thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(VALID_LOGIN_ID, VALID_PASSWORD, NEW_PASSWORD); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(userRepository, never()).save(any()); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμœΌλ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenCurrentPasswordDoesNotMatch() { + // 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("wrongPw@123", VALID_ENCRYPTED_PASSWORD)) + .thenReturn(false); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(VALID_LOGIN_ID, "wrongPw@123", NEW_PASSWORD); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(userRepository, never()).save(any()); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenNewPasswordIsSameAsCurrent() { + // 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)); + // currentPassword == newPassword == VALID_PASSWORD μ΄λ―€λ‘œ, + // 1μ°¨ 호좜(ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 확인)κ³Ό 2μ°¨ 호좜(μƒˆ λΉ„λ°€λ²ˆν˜Έ == ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 확인) λͺ¨λ‘ 이 stub에 λ§€μΉ­λœλ‹€ + when(passwordEncoder.matches(VALID_PASSWORD, VALID_ENCRYPTED_PASSWORD)) + .thenReturn(true); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(VALID_LOGIN_ID, VALID_PASSWORD, VALID_PASSWORD); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(userRepository, never()).save(any()); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ κ·œμΉ™μ— μœ„λ°˜λ˜λ©΄, μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsException_whenNewPasswordViolatesRules() { + // 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); + when(passwordEncoder.matches("short", VALID_ENCRYPTED_PASSWORD)) + .thenReturn(false); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(VALID_LOGIN_ID, VALID_PASSWORD, "short"); + }); + + // 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 new file mode 100644 index 00000000..69af5241 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,344 @@ +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; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import 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 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"; + + 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(EXPECTED_MASKED_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( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @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 = + // httpEntity둜 null을 μ „λ‹¬ν•˜λ©΄ λ³Έλ¬Έ 및 헀더 λ‘˜ λ‹€ μ—†λ‹€λŠ” 의미이기 λ•Œλ¬Έμ— 빈 HttpEntityλ₯Ό μ „λ‹¬ν•˜μž. + testRestTemplate.exchange(ME_ENDPOINT, HttpMethod.GET, HttpEntity.EMPTY, 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) + ); + } + } +}