diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 00000000..08ecbf43 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,593 @@ +# CLAUDE.md + +이 파일은 Claude Code가 이 프로젝트를 이해하는 데 필요한 컨텍스트를 제공합니다. + +## 프로젝트 개요 + +Spring + Java 기반 멀티 모듈 이커머스 프로젝트입니다. 상품, 주문, 회원, 결제 등 커머스 도메인을 직접 설계하고 구현하며, API, Batch, Streamer 애플리케이션으로 구성됩니다. + +## 기술 스택 및 버전 + +### Core +- **Java**: 21 +- **Spring Boot**: 3.4.4 +- **Spring Cloud**: 2024.0.1 +- **Gradle**: 8.13 + +### Data +- **Spring Data JPA** + **QueryDSL** (Jakarta) +- **MySQL** (mysql-connector-j) +- **Spring Data Redis** +- **Spring Kafka** + +### Serialization +- **Jackson** (jackson-datatype-jsr310, jackson-module-kotlin) + +### Monitoring & Logging +- **Micrometer** + **Prometheus** +- **Micrometer Tracing** (Brave) +- **Logback Slack Appender**: 1.6.1 + +### Documentation +- **SpringDoc OpenAPI**: 2.7.0 + +### Testing +- **JUnit 5** (junit-platform-launcher) +- **Mockito**: 5.14.0 +- **SpringMockK**: 4.0.2 +- **Instancio**: 5.0.2 +- **Testcontainers** (MySQL, Redis, Kafka) + +### Build Tools +- **Lombok** +- **JaCoCo** (코드 커버리지) + +## 모듈 구조 + +``` +Root (loopers-java-spring-template) +├── apps/ # 실행 가능한 Spring Boot 애플리케이션 +│ ├── commerce-api/ # REST API 서버 (Web, Actuator, OpenAPI) +│ ├── commerce-batch/ # Spring Batch 애플리케이션 +│ └── commerce-streamer/ # Kafka Consumer 애플리케이션 +├── modules/ # 재사용 가능한 설정 모듈 +│ ├── jpa/ # JPA + QueryDSL 설정 +│ ├── redis/ # Redis 설정 +│ └── kafka/ # Kafka 설정 +└── supports/ # 부가 기능 애드온 모듈 + ├── jackson/ # Jackson 직렬화 설정 + ├── logging/ # Prometheus + Slack Appender + └── monitoring/ # Micrometer + Prometheus +``` + +### 모듈 의존성 관계 +- **commerce-api**: jpa, redis, jackson, logging, monitoring +- **commerce-batch**: jpa, redis, jackson, logging, monitoring +- **commerce-streamer**: jpa, redis, kafka, jackson, logging, monitoring + +## 빌드 및 실행 + +### 로컬 인프라 실행 +```bash +docker-compose -f ./docker/infra-compose.yml up +``` + +### 모니터링 환경 실행 +```bash +docker-compose -f ./docker/monitoring-compose.yml up +# Grafana: http://localhost:3000 (admin/admin) +``` + +### 빌드 +```bash +./gradlew build +``` + +### 테스트 +```bash +./gradlew test +``` +- 테스트는 `test` 프로파일로 실행됨 +- 타임존: `Asia/Seoul` +- Testcontainers로 MySQL, Redis, Kafka 컨테이너 자동 생성 + +### 특정 앱 실행 +```bash +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-batch:bootRun +./gradlew :apps:commerce-streamer:bootRun +``` + +## 프로젝트 설정 + +- **그룹**: `com.loopers` +- **버전**: Git hash 기반 자동 생성 +- **패키지 구조**: `com.loopers.*` + +## 주요 패턴 + +### 테스트 패턴 +- `testFixtures` 플러그인 사용 (jpa, redis, kafka 모듈) +- Testcontainers 기반 통합 테스트 +- E2E 테스트: `*E2ETest.java` +- 통합 테스트: `*IntegrationTest.java` + +### 모듈 규칙 +- **apps**: BootJar 활성화, 일반 Jar 비활성화 +- **modules/supports**: 일반 Jar 활성화, BootJar 비활성화 + +### 필수 연관 문서 +- plan.md 필수 참고 + +## 개발자 철학 (API 구현 규칙) + +아래 규칙은 이 프로젝트에서 API를 구현할 때 반드시 따라야 하는 원칙이다. + +### 1. 에러 처리: 검증 실패는 예외가 아닌 Response Return + +- 비즈니스 로직의 **검증 단계에서 발생하는 에러**는 `try/catch` 예외로 던지지 않고, **직접 Response를 return**한다. +- 예외(`throw`)는 **예상치 못한 시스템 에러**에만 사용한다. +- 검증 실패 시 클라이언트에게 **왜 실패했는지 명확한 메시지**를 포함한 응답을 반환한다. +- **의미 없는 `try/catch`는 지양한다.** 별도 처리 로직 없이 단순히 감싸기만 하는 `try/catch`는 작성하지 않는다. + +```java +// Good: 검증 실패 → Response return +if (user == null) { + return ApiResponse.error("존재하지 않는 사용자입니다."); +} + +// Bad: 검증 실패를 예외로 던짐 +if (user == null) { + throw new CoreException(ErrorType.USER_NOT_FOUND); +} + +// Bad: 의미 없는 try/catch — 잡아서 다시 던지기만 하거나 아무 처리도 없음 +try { + userRepository.save(user); +} catch (Exception e) { + throw e; +} +``` + +### 2. 로깅 및 에러 메시지 분리 + +- **시스템 로그**: 예외 발생 시 `e`가 아닌 구체적인 정보(요청 파라미터, 상태값 등)를 로그에 남긴다. +- **클라이언트 응답**: 시스템 내부 에러 메시지를 노출하지 않고, 사용자가 이해할 수 있는 실패 사유 메시지를 반환한다. +- **예외 전파 시 메시지**: 예외를 감싸서 다시 던질 때 `e` 전체를 그대로 던지지 않고, **사용자가 이해할 수 있는 정확한 메시지**를 직접 작성하여 전달한다. 내부 스택트레이스나 시스템 정보가 그대로 노출되지 않도록 한다. + +```java +// Good: 구체적인 로그 + 사용자 친화적 응답 +log.error("사용자 조회 실패 - userId: {}, reason: {}", userId, e.getMessage(), e); +return ApiResponse.error("사용자 정보를 불러올 수 없습니다. 잠시 후 다시 시도해주세요."); + +// Good: 예외 감싸서 던질 때 — 정확한 메시지 + cause 보존 +catch (DateTimeParseException e) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, + "생년월일은 YYYY-MM-DD 형식이어야 합니다.", e); + // 클라이언트에는 "YYYY-MM-DD 형식이어야 합니다" 전달 + // 시스템 로그에는 cause(DateTimeParseException)로 원인 추적 가능 +} + +// Bad: 모호한 로그 + 시스템 에러 노출 +log.error("error", e); +return ApiResponse.error(e.getMessage()); + +// Bad: e 전체를 그대로 메시지로 노출 +catch (DateTimeParseException e) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, e.toString()); + // "java.time.format.DateTimeParseException: Text '1994/11/15'..." 가 클라이언트에 노출됨 +} +``` + +### 3. 주석 규칙 + +- **정책/도메인 규칙**: 다른 개발자가 반드시 알아야 하는 비즈니스 정책은 상세한 주석을 작성한다. +- **일반 코드**: 간결한 주석만 작성한다. 코드 자체로 의도가 명확하면 주석을 생략한다. + +### 4. 테스트 코드 규칙 + +- 테스트 메서드명은 **한글**로 작성한다. +- 클래스에 `@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)`를 선언하여 언더스코어(`_`)가 공백으로 치환되도록 한다. + +```java +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserServiceTest { + + @Test + void 존재하지_않는_사용자_조회_시_에러_응답을_반환한다() { ... } + + @Test + void 비밀번호가_정책에_부합하지_않으면_에러_응답을_반환한다() { ... } +} +``` + +### 5. 함수명 및 URI 네이밍 규칙 + +- Controller, Service의 함수명은 **REST/도메인 관점**(`get`, `create`, `update`, `delete`)으로 작성한다. +- Repository는 Spring Data JPA 컨벤션(`find`, `save`, `delete`)을 따른다. +- 함수명에서 **단수와 복수를 혼합하지 않는다**. 일관되게 단수 또는 복수를 사용한다. +- URI와 함수명은 HTTP 메서드와 리소스 단/복수에 맞춰 아래 패턴을 따른다. + +| HTTP Method | URI 예시 | Controller/Service 함수명 | Repository 함수명 | +|-------------|----------|--------------------------|-------------------| +| GET | `/users` | `getUsers` | `findAll` | +| GET | `/users/{user_no}` | `getUser` | `findByUserNo` | +| POST | `/users` | `createUser` | `save` | +| PUT | `/users/{user_no}` | `updateUser` | `save` | +| DELETE | `/users/{user_no}` | `deleteUser` | `save` (소프트 삭제) | + +- **Path Variable 사용 기준**: 노출되어도 무방한 식별자(예: `user_no`)만 Path에 포함한다. 민감 정보(이메일, 주민번호 등)는 Path에 노출하지 않는다. + +### 6. 에러 코드 사용 기준 + +- **검증 단계 실패**: 에러 코드 없이, 직접 Response return으로 실패 사유를 명시한다. +- **예상치 못한 예외**: 에러 코드를 사용하여 시스템 로그에 추적 가능하도록 한다. + +### 7. 모든 CRUD 작업에 검증 단계 필수 + +- **조회, 등록, 수정, 삭제** 모든 작업에서 데이터 검증 단계를 반드시 거친다. +- 클라이언트로부터 과도한 정보를 받아 그대로 처리하지 않는다. **검증된 데이터만으로** 로직을 수행한다. +- 잘못된 데이터가 조회되거나 존재하지 않는 데이터에 대한 요청이 들어오면, 즉시 실패 Response를 return한다. + +```java +// Good: 검증 후 처리 +public ApiResponse updateUserInfo(Long userNo, UpdateUserRequest request) { + User user = userRepository.findById(userNo).orElse(null); + if (user == null) { + return ApiResponse.error("존재하지 않는 사용자입니다."); + } + if (user.isDeleted()) { + return ApiResponse.error("이미 탈퇴한 사용자입니다."); + } + // 검증 통과 후 업데이트 수행 + user.updateInfo(request.getNickname()); + return ApiResponse.success(user); +} + +// Bad: 검증 없이 바로 처리 +public ApiResponse updateUserInfo(Long userNo, UpdateUserRequest request) { + userRepository.updateByUserNo(userNo, request.getNickname()); + return ApiResponse.success(); +} +``` + +### 8. 삭제는 소프트 삭제(Soft Delete)를 지향한다 + +- 데이터를 물리적으로 삭제(`DELETE FROM`)하지 않고, **`deleted_at` 컬럼을 업데이트**하여 논리 삭제를 수행한다. +- 삭제 API의 내부 구현은 실질적으로 **UPDATE** 처리이다. +- 조회 시 `deleted_at IS NULL` 조건으로 삭제되지 않은 데이터만 필터링한다. + +```java +// Good: 소프트 삭제 — deleted_at 업데이트 +public ApiResponse deleteUser(Long userNo) { + User user = userRepository.findById(userNo).orElse(null); + if (user == null) { + return ApiResponse.error("존재하지 않는 사용자입니다."); + } + if (user.isDeleted()) { + return ApiResponse.error("이미 탈퇴한 사용자입니다."); + } + user.delete(); // deleted_at = LocalDateTime.now() + return ApiResponse.success(); +} + +// Bad: 물리 삭제 +public ApiResponse deleteUser(Long userNo) { + userRepository.deleteById(userNo); + return ApiResponse.success(); +} +``` + +## RESTful API 컨벤션 + +### 1. URI는 동사가 아닌 명사를 사용한다 + +- 리소스를 나타내는 URI에는 행위(동사)가 아닌 **명사**를 사용한다. +- 행위는 HTTP Method(GET, POST, PUT, DELETE)로 표현한다. + +``` +# Good +GET /users +POST /users +PUT /users/{user_no} +DELETE /users/{user_no} + +# Bad +GET /getUsers +POST /createUser +PUT /updateUser +DELETE /removeUser +``` + +### 2. 긴 URI에는 하이픈(`-`)을 사용한다 + +- URI가 길어질 경우 단어 구분자로 **하이픈(`-`)**을 사용한다. +- 언더스코어(`_`)나 camelCase는 사용하지 않는다. + +``` +# Good +GET /user-addresses +GET /order-histories + +# Bad +GET /user_addresses +GET /orderHistories +``` + +### 3. 필터링은 기존 GET API에 쿼리 파라미터로 추가한다 + +- 필터링 조건이 필요할 때 **새로운 API를 만들지 않고**, 기존 GET API에 **URL 쿼리 파라미터**를 붙여서 처리한다. + +``` +# Good: 기존 API에 쿼리 파라미터로 필터링 +GET /users?status=active +GET /users?status=active&role=admin +GET /orders?start-date=2026-01-01&end-date=2026-01-31 + +# Bad: 필터링 조건별로 새 API 생성 +GET /active-users +GET /admin-users +GET /orders-by-date +``` + +## 코드 품질 컨벤션 + +### 1. DTO 분리 (Request/Response) + +- Controller에서 **Entity를 직접 노출하지 않는다**. 요청/응답 전용 DTO를 사용한다. +- 내부 Entity 구조가 변경되더라도 API 스펙에 영향을 주지 않도록 분리한다. + +```java +// Good: 전용 DTO 사용 +@PostMapping("/users") +public ApiResponse insertUsers(@RequestBody InsertUserRequest request) { + ... + return ApiResponse.success(InsertUserResponse.from(user)); +} + +// Bad: Entity 직접 노출 +@PostMapping("/users") +public ApiResponse insertUsers(@RequestBody User user) { + ... + return ApiResponse.success(userRepository.save(user)); +} +``` + +### 2. 계층 간 의존성 규칙 + +- **`Controller → Service → Repository`** 단방향만 허용한다. +- Service가 Controller를 참조하거나, Repository가 Service를 참조하는 **역방향 의존은 금지**한다. + +``` +# Good +Controller → Service → Repository + +# Bad +Controller ← Service (역방향) +Repository → Service (역방향) +Service → Controller (역방향) +``` + +### 3. 트랜잭션 관리 + +- **조회 메서드**에는 `@Transactional(readOnly = true)`를 명시하여 불필요한 쓰기 잠금을 방지한다. +- **변경 작업**(등록, 수정, 삭제)에만 `@Transactional`을 사용한다. + +```java +// Good: 조회는 readOnly +@Transactional(readOnly = true) +public ApiResponse selectUsers() { ... } + +// Good: 변경은 @Transactional +@Transactional +public ApiResponse updateUserInfo(Long userNo, UpdateUserRequest request) { ... } +``` + +### 4. 매직 넘버/문자열 금지 + +- 코드 내 의미가 불명확한 숫자나 문자열 리터럴을 직접 사용하지 않는다. +- **`상수(static final)`** 또는 **`enum`**으로 관리한다. + +```java +// Good: 상수로 관리 +private static final int MAX_LOGIN_ATTEMPTS = 5; +if (loginAttempts >= MAX_LOGIN_ATTEMPTS) { ... } + +// Bad: 매직 넘버 +if (loginAttempts >= 5) { ... } +``` + +### 5. 메서드 단일 책임 + +- 하나의 메서드는 **하나의 역할만** 수행한다. +- 메서드가 길어지면 의미 단위로 **private 메서드로 분리**한다. + +```java +// Good: 역할별 분리 +public ApiResponse insertUsers(InsertUserRequest request) { + ApiResponse validationResult = validateInsertRequest(request); + if (validationResult != null) { + return validationResult; + } + User user = createUser(request); + return ApiResponse.success(InsertUserResponse.from(user)); +} + +private ApiResponse validateInsertRequest(InsertUserRequest request) { ... } +private User createUser(InsertUserRequest request) { ... } +``` + +### 6. Null 안전성 + +- `null`을 직접 반환하거나 비교하기보다 **`Optional`을 적극 활용**한다. +- 단, **Entity 필드에는 `Optional`을 사용하지 않고**, 조회 반환 시에만 사용한다. + +```java +// Good: Optional 활용 +Optional userOpt = userRepository.findByUserNo(userNo); +if (userOpt.isEmpty()) { + return ApiResponse.error("존재하지 않는 사용자입니다."); +} +User user = userOpt.get(); + +// Bad: null 직접 비교 +User user = userRepository.findByUserNo(userNo); +if (user == null) { ... } +``` + +### 7. 순환 참조 금지 + +- Service 간, 또는 계층 간 **순환 참조(Circular Dependency)를 절대 허용하지 않는다**. +- 순환이 발생할 경우 공통 로직을 별도 Service로 분리하거나, 이벤트 기반으로 의존을 끊는다. + +``` +# Bad: 순환 참조 +UserService → OrderService → UserService + +# Good: 공통 로직 분리 +UserService → UserOrderFacade ← OrderService +``` + +### 8. Lombok 사용 금지 + +- **Lombok을 사용하지 않는다.** 생성자, getter 등을 직접 작성한다. +- IDE의 코드 생성 기능이나 `record`를 활용하여 보일러플레이트를 줄인다. + +```java +// Good: 직접 작성 +public class User { + private final String name; + + public User(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } +} + +// Bad: Lombok 사용 +@Getter +@AllArgsConstructor +public class User { + private String name; +} +``` + +### 9. 의존성 주입은 생성자 + `this` 접두어로 불변성 보장 + +- 의존성 주입은 **생성자 주입**을 사용하고, 필드는 `private final`로 선언한다. +- 호출 시 반드시 **`this.`** 접두어를 붙여 인스턴스 필드임을 명확히 한다. + +```java +// Good: 생성자 주입 + this 사용 +public class UserService { + private final UserRepository userRepository; + private final UserMapper userMapper; + + public UserService(UserRepository userRepository, UserMapper userMapper) { + this.userRepository = userRepository; + this.userMapper = userMapper; + } + + public ApiResponse selectUsers() { + List users = this.userRepository.findAll(); + return ApiResponse.success(this.userMapper.toResponseList(users)); + } +} + +// Bad: @Autowired 필드 주입 + this 미사용 +@Service +public class UserService { + @Autowired + private UserRepository userRepository; + + public ApiResponse selectUsers() { + return ApiResponse.success(userRepository.findAll()); + } +} +``` + +### 10. DTO는 `record`를 사용한다 + +- Request/Response DTO는 Java `record`로 선언하여 **불변성을 보장**한다. +- `record`는 생성자, getter, `equals`, `hashCode`, `toString`을 자동 제공한다. + +```java +// Good: record 사용 +public record InsertUserRequest( + String email, + String password, + String nickname +) {} + +public record InsertUserResponse( + Long userNo, + String email, + String nickname +) { + public static InsertUserResponse from(User user) { + return new InsertUserResponse(user.getUserNo(), user.getEmail(), user.getNickname()); + } +} + +// Bad: class + getter +public class InsertUserRequest { + private String email; + private String password; + private String nickname; + // getter/setter ... +} +``` + +### 11. Entity 외부 노출 금지 + +- **Entity를 Controller 응답으로 직접 반환하지 않는다.** +- 반드시 Response DTO로 변환하여 반환한다. 이는 내부 테이블 구조 및 민감 필드 노출을 방지한다. + +### 12. Null 체크 검증 로직 필수 + +- 모든 조회 결과, 외부 입력값, 파라미터에 대해 **null 체크를 필수로 수행**한다. +- null일 경우 즉시 실패 Response를 return한다. null 상태로 로직을 계속 진행하지 않는다. + +```java +// Good: null 체크 후 즉시 return +public ApiResponse selectUser(Long userNo) { + if (userNo == null) { + return ApiResponse.error("사용자 번호는 필수입니다."); + } + Optional userOpt = this.userRepository.findByUserNo(userNo); + if (userOpt.isEmpty()) { + return ApiResponse.error("존재하지 않는 사용자입니다."); + } + return ApiResponse.success(SelectUserResponse.from(userOpt.get())); +} + +// Bad: null 체크 없이 진행 +public ApiResponse selectUser(Long userNo) { + User user = this.userRepository.findByUserNo(userNo).get(); // NoSuchElementException 위험 + return ApiResponse.success(user); // Entity 직접 노출 +} +``` + +## 소프트웨어 설계 원칙 + +### 1. 기술 도입은 문제 정의가 먼저다 + +- 새로운 라이브러리나 기술을 도입할 때, **해결하려는 문제를 먼저 명확히 정의**한다. +- 유행이나 편의가 아닌, 성능·복원력·유지보수성을 기준으로 가장 단순하고 강건한 방법을 선택한다. + +### 2. 설계의도를 고려한다. + +- 서비스 경계를 명확히 하고, 상태 동기화 흐름을 문서화한다. + +### 3. 개선과 실험은 반드시 테스트로 검증한다 + +- 성능 개선, 구조 변경 등은 **테스트 코드로 검증 가능한 상태**에서 진행한다. +- 추측이 아닌, 재현 가능한 테스트와 측정 데이터를 근거로 의사결정한다. + +### 4. 설계 의도는 코드와 문서에 함께 남긴다 + +- 비즈니스 플로우, API 계약, 서비스 간 데이터 흐름 등 **설계 의도를 문서와 주석으로 명시**한다. +- 코드만으로 파악하기 어려운 도메인 맥락은 반드시 기록한다. \ No newline at end of file diff --git a/.claude/plan.md b/.claude/plan.md new file mode 100644 index 00000000..073b3aed --- /dev/null +++ b/.claude/plan.md @@ -0,0 +1,158 @@ +# AI 협업 개발 가이드 + +본 문서는 AI 도구(Claude Code)와의 효과적인 협업을 위한 개발 원칙을 정의합니다. + +## 1. 협업 철학 - 증강 코딩 (Augmented Coding) + +AI는 개발자의 역량을 증강하는 도구이며, 의사결정의 주체는 개발자입니다. + +- **제안과 승인**: AI는 방향성과 대안을 제안하고, 개발자가 최종 승인 +- **설계 주도권**: 아키텍처와 설계 결정은 개발자가 주도 +- **중간 개입**: 반복 동작, 미요청 기능 구현, 임의 테스트 삭제 시 개발자가 개입 + +## 2. 개발 방법론 - TDD (Red → Green → Refactor) + +모든 코드는 테스트 주도 개발 방식으로 작성합니다. + +### 2.1 Red Phase +- 요구사항을 만족하는 실패 테스트 케이스 먼저 작성 +- 3A 원칙 준수 (Arrange - Act - Assert) + +### 2.2 Green Phase +- 테스트를 통과하는 최소한의 코드 작성 +- 오버엔지니어링 금지 + +### 2.3 Refactor Phase +- 코드 품질 개선 및 불필요한 코드 제거 +- 객체지향 원칙 준수 +- 모든 테스트 통과 필수 + +## 3. 코드 품질 기준 + +### 3.1 금지 사항 (Never Do) +- Mock 남발: 실제 동작하지 않는 코드, 과도한 Mock 사용 금지 +- null-safety 위반: Optional 활용 필수 (Java) +- 디버깅 코드: println, System.out 등 잔류 금지 + +### 3.2 권장 사항 (Recommendation) +- E2E 테스트로 실제 API 동작 검증 +- 재사용 가능한 객체 설계 +- 성능 최적화 대안 제시 +- 완성된 API는 `http/**/*.http`에 문서화 + +### 3.3 우선순위 (Priority) +1. 실제 동작하는 해결책만 고려 +2. null-safety, thread-safety 보장 +3. 테스트 가능한 구조 설계 +4. 기존 코드 패턴과 일관성 유지 + +## 4. 오류 수정 컨벤션 + +오류를 수정할 때는 반드시 아래 형식으로 **오류 수정 이력** 섹션에 기록한다. + +| 항목 | 설명 | +|------|------| +| **AS-IS** | 수정 전 코드 | +| **TO-BE** | 수정 후 코드 | +| **왜 (Why)** | 왜 이 수정이 필요한지 | +| **동작 원리** | 내부적으로 어떻게 동작하는지, 이유 | +| **검증 테스트** | 수정이 올바른지 확인하는 테스트 | + +## 5. Git 컨벤션 + +- **커밋 주체**: 개발자진행 방향가 직접 수행 (AI 임의 커밋 금지) +- **커밋 메시지**: Conventional Commits 형식 권장 + +## 6. AI 협업 스타일 + +본 프로젝트에서 AI와의 협업은 다음 방식을 지향합니다: + +| 스타일 | 설명 | +|--------|------| +| **Planning-first** | 개발자가 먼저 설계하고, AI로 검증 및 대안 비교 | +| **Explanation-seeking** | 코드의 이유, 원리, 동작에 대한 설명 요구 | +| **Iterative-reasoning** | 문제를 분해하여 추론 → 질문 → 수정 반복 | + +> AI는 답을 제공하는 것이 아닌, 사고를 돕는 도구로 활용합니다. + +--- + +## 오류 수정 이력 + +오류 수정 시 AS-IS / TO-BE / 왜(Why) / 동작 원리를 반드시 기록한다. + +--- + +### [#1] CoreException 예외 원인(cause) 유실 문제 + +**출처**: CodeRabbit 리뷰 + +#### AS-IS + +```java +// CoreException — cause를 받는 생성자 없음 +public CoreException(ErrorType errorType, String customMessage) { + super(customMessage != null ? customMessage : errorType.getMessage()); + this.errorType = errorType; + this.customMessage = customMessage; +} + +// BirthDate.parseDate — 원래 예외(e)를 전달하지 않음 +catch (DateTimeParseException e) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, + "생년월일은 YYYY-MM-DD 형식이어야 합니다."); +} +``` + +#### TO-BE + +```java +// CoreException — cause를 받는 3-파라미터 생성자 추가 +public CoreException(ErrorType errorType, String customMessage, Throwable cause) { + super(customMessage != null ? customMessage : errorType.getMessage(), cause); + this.errorType = errorType; + this.customMessage = customMessage; +} + +// BirthDate.parseDate — 원래 예외(e)를 cause로 전달 +catch (DateTimeParseException e) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, + "생년월일은 YYYY-MM-DD 형식이어야 합니다.", e); +} +``` + +#### 왜 (Why) + +`catch`에서 새로운 예외를 던질 때 원래 예외(`e`)를 넘기지 않으면 **예외 체인(Exception Chain)이 끊긴다.** +운영 환경에서 장애가 발생했을 때 로그에 `Caused by`가 남지 않아, 정확히 어떤 입력값이 왜 실패했는지 추적할 수 없다. + +#### 동작 원리 + +Java의 모든 예외는 `Throwable.cause` 필드를 가진다. `super(message, cause)`로 원인을 연결하면 예외 체인이 형성된다. + +``` +// cause 없는 경우 — 원인 추적 불가 +CoreException: 생년월일은 YYYY-MM-DD 형식이어야 합니다. + at BirthDate.parseDate(BirthDate.java:52) + +// cause 있는 경우 — 원인 추적 가능 +CoreException: 생년월일은 YYYY-MM-DD 형식이어야 합니다. + at BirthDate.parseDate(BirthDate.java:52) +Caused by: DateTimeParseException: Text '1994/11/15' could not be parsed + at java.time.format.DateTimeFormatter.parseResolved0(...) +``` + +`Caused by`가 있어야 "어떤 값이, 어떤 이유로 파싱에 실패했는지" 정확히 파악할 수 있다. 이는 운영 환경에서 장애 원인 파악 시간을 줄이는 데 직결된다. + +#### 검증 테스트 + +```java +@DisplayName("잘못된 형식이면, 예외의 원인으로 DateTimeParseException을 포함한다.") +@Test +void preservesCauseWhenInvalidFormat() { + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("1994/11/15"); + }); + assertThat(exception.getCause()).isInstanceOf(DateTimeParseException.class); +} +``` \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5a979af6..6065508e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +### HTTP Client ### +http-client.private.env.json diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..db169be0 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -8,6 +8,9 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + + // security (BCrypt) + implementation("org.springframework.security:spring-security-crypto") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 9027b51b..9c056332 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -12,7 +12,7 @@ public class CommerceApiApplication { @PostConstruct public void started() { - // set timezone + // JVM 기본 타임존을 Asia/Seoul로 설정하여 DB 저장, 로그 출력 시 시간대 일관성을 보장한다. TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad6..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} 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..5f562c8f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,32 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +import java.time.LocalDate; + +/** + * 사용자 정보 DTO (Application Layer) + * + * Entity를 외부 계층에 노출하지 않기 위한 변환용 DTO. + * maskedName은 개인정보 보호를 위해 이름의 마지막 글자를 마스킹한 값이다. + */ +public record UserInfo( + String loginId, + String name, + String maskedName, + LocalDate birthDate, + String email +) { + public static UserInfo from(User user) { + if (user == null) { + throw new IllegalArgumentException("User는 null일 수 없습니다."); + } + return new UserInfo( + user.getLoginId().getValue(), + user.getName().getValue(), + user.getName().getMaskedValue(), + user.getBirthDate().getValue(), + user.getEmail().getValue() + ); + } +} 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..cee2a341 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,29 @@ +package com.loopers.config; + +import com.loopers.support.auth.AuthUserResolver; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +/** + * Spring MVC 설정 + * + * {@link AuthUserResolver}를 ArgumentResolver로 등록하여 + * {@code @AuthUser} 어노테이션 기반의 인증된 사용자 주입을 활성화한다. + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthUserResolver authUserResolver; + + public WebMvcConfig(AuthUserResolver authUserResolver) { + this.authUserResolver = authUserResolver; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(this.authUserResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -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; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e566..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncryptor.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncryptor.java new file mode 100644 index 00000000..432e274f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncryptor.java @@ -0,0 +1,27 @@ +package com.loopers.domain.user; + +/** + * 비밀번호 암호화 포트 (Domain Layer) + * + * Domain이 인프라(Spring Security)에 의존하지 않도록 추상화한 인터페이스. + * 실제 구현은 Infrastructure 계층의 어댑터가 담당한다. + */ +public interface PasswordEncryptor { + + /** + * 평문 비밀번호를 암호화한다. + * + * @param rawPassword 평문 비밀번호 + * @return 암호화된 비밀번호 + */ + String encode(String rawPassword); + + /** + * 평문 비밀번호와 암호화된 비밀번호가 일치하는지 확인한다. + * + * @param rawPassword 평문 비밀번호 + * @param encodedPassword 암호화된 비밀번호 + * @return 일치 여부 + */ + boolean matches(String rawPassword, String encodedPassword); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 00000000..3b39260d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,76 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.user.vo.BirthDate; +import com.loopers.domain.user.vo.Email; +import com.loopers.domain.user.vo.LoginId; +import com.loopers.domain.user.vo.UserName; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +/** + * 사용자 엔티티 (Aggregate Root) + * + * 각 필드는 Value Object로 자체 검증을 수행하며, + * password는 암호화된 값만 저장한다 (평문 저장 금지). + */ +@Entity +@Table(name = "users") +public class User extends BaseEntity { + + @Embedded + private LoginId loginId; + + /** 암호화된 비밀번호 (BCrypt 해시) */ + @Column(name = "password", nullable = false) + private String password; + + @Embedded + private UserName name; + + @Embedded + private BirthDate birthDate; + + @Embedded + private Email email; + + protected User() {} + + private User(LoginId loginId, String encodedPassword, UserName name, BirthDate birthDate, Email email) { + this.loginId = loginId; + this.password = encodedPassword; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public static User create(LoginId loginId, String encodedPassword, UserName name, BirthDate birthDate, Email email) { + return new User(loginId, encodedPassword, name, birthDate, email); + } + + public void changePassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } + + public LoginId getLoginId() { + return this.loginId; + } + + public String getPassword() { + return this.password; + } + + public UserName getName() { + return this.name; + } + + public BirthDate getBirthDate() { + return this.birthDate; + } + + public Email getEmail() { + return this.email; + } +} 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..a27484ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +/** + * 사용자 리포지토리 포트 (Domain Layer) + * + * 도메인이 인프라(JPA)에 의존하지 않도록 추상화한 인터페이스. + * 실제 구현은 Infrastructure 계층의 UserRepositoryImpl이 담당한다. + */ +public interface UserRepository { + User save(User user); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} 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..5f620386 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,126 @@ +package com.loopers.domain.user; + +import com.loopers.domain.user.vo.BirthDate; +import com.loopers.domain.user.vo.Email; +import com.loopers.domain.user.vo.LoginId; +import com.loopers.domain.user.vo.Password; +import com.loopers.domain.user.vo.UserName; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * 사용자 도메인 서비스 + * + * 회원가입, 인증, 비밀번호 변경 등 사용자 도메인 핵심 비즈니스 로직을 담당한다. + */ +@Component +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncryptor passwordEncryptor; + + public UserService(UserRepository userRepository, PasswordEncryptor passwordEncryptor) { + this.userRepository = userRepository; + this.passwordEncryptor = passwordEncryptor; + } + + /** + * 회원가입: VO 생성(형식 검증) → 비밀번호-생년월일 교차검증 → 로그인 ID 중복 확인 → 비밀번호 암호화 → 저장 + */ + @Transactional + public User createUser(String rawLoginId, String rawPassword, String rawName, String rawBirthDate, String rawEmail) { + LoginId loginId = new LoginId(rawLoginId); + Password password = Password.of(rawPassword); + UserName name = new UserName(rawName); + BirthDate birthDate = new BirthDate(rawBirthDate); + Email email = new Email(rawEmail); + + // 비밀번호에 생년월일 포함 불가 + validatePasswordNotContainsBirthDate(rawPassword, birthDate.getValue()); + + if (this.userRepository.existsByLoginId(loginId.getValue())) { + throw new CoreException(UserErrorType.DUPLICATE_LOGIN_ID); + } + + String encodedPassword = this.passwordEncryptor.encode(rawPassword); + User user = User.create(loginId, encodedPassword, name, birthDate, email); + return this.userRepository.save(user); + } + + /** + * 로그인 인증: 필수값 검증 → 사용자 조회 → 비밀번호 일치 확인 + */ + @Transactional(readOnly = true) + public User authenticateUser(String rawLoginId, String rawPassword) { + if (rawLoginId == null || rawLoginId.isBlank()) { + throw new CoreException(UserErrorType.UNAUTHORIZED, "로그인 ID는 필수입니다."); + } + if (rawPassword == null || rawPassword.isBlank()) { + throw new CoreException(UserErrorType.UNAUTHORIZED, "비밀번호는 필수입니다."); + } + + User user = this.userRepository.findByLoginId(rawLoginId) + .orElseThrow(() -> new CoreException(UserErrorType.UNAUTHORIZED)); + + if (!this.passwordEncryptor.matches(rawPassword, user.getPassword())) { + throw new CoreException(UserErrorType.UNAUTHORIZED); + } + + return user; + } + + /** + * 비밀번호 변경: 필수값 검증 → 현재 비밀번호 확인 → 새 비밀번호 정책 검증 → 생년월일 교차검증 → 기존 비밀번호 동일 여부 확인 → 암호화 후 저장 + */ + @Transactional + public void updateUserPassword(User user, String currentRawPassword, String newRawPassword) { + if (user == null) { + throw new CoreException(UserErrorType.USER_NOT_FOUND, "사용자 정보가 존재하지 않습니다."); + } + if (currentRawPassword == null || currentRawPassword.isBlank()) { + throw new CoreException(UserErrorType.INVALID_PASSWORD, "현재 비밀번호는 필수입니다."); + } + if (newRawPassword == null || newRawPassword.isBlank()) { + throw new CoreException(UserErrorType.INVALID_PASSWORD, "새 비밀번호는 필수입니다."); + } + + if (!this.passwordEncryptor.matches(currentRawPassword, user.getPassword())) { + throw new CoreException(UserErrorType.PASSWORD_MISMATCH); + } + + Password.of(newRawPassword); + + BirthDate birthDate = user.getBirthDate(); + if (birthDate == null) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 필수입니다."); + } + validatePasswordNotContainsBirthDate(newRawPassword, birthDate.getValue()); + + if (this.passwordEncryptor.matches(newRawPassword, user.getPassword())) { + throw new CoreException(UserErrorType.SAME_PASSWORD); + } + + String newEncodedPassword = this.passwordEncryptor.encode(newRawPassword); + user.changePassword(newEncodedPassword); + this.userRepository.save(user); + } + + /** 비밀번호에 생년월일(YYYYMMDD, YYMMDD, MMDD) 포함 금지 */ + private void validatePasswordNotContainsBirthDate(String rawPassword, LocalDate birthDate) { + if (birthDate == null) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 필수입니다."); + } + String yyyymmdd = birthDate.format(DateTimeFormatter.BASIC_ISO_DATE); + String[] patterns = { yyyymmdd, yyyymmdd.substring(2), yyyymmdd.substring(4) }; + for (String pattern : patterns) { + if (rawPassword.contains(pattern)) { + throw new CoreException(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/BirthDate.java new file mode 100644 index 00000000..7c99186c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/BirthDate.java @@ -0,0 +1,73 @@ +package com.loopers.domain.user.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.time.LocalDate; +import java.time.Period; +import java.time.format.DateTimeParseException; + +/** + * 생년월일 Value Object + * + * 검증 규칙: + * - YYYY-MM-DD 형식 (ISO 8601) + * - 1900-01-01 ~ 현재 날짜 + * - 실제 존재하는 날짜 + * - 만 14세 이상 + */ +@Embeddable +public class BirthDate { + + private static final LocalDate MIN_DATE = LocalDate.of(1900, 1, 1); + private static final int MIN_AGE = 14; + + @Column(name = "birth_date") + private LocalDate value; + + protected BirthDate() {} + + public BirthDate(String rawValue) { + validateNotBlank(rawValue); + this.value = parseDate(rawValue); + validateRange(this.value); + } + + public LocalDate getValue() { + return value; + } + + private static void validateNotBlank(String rawValue) { + if (rawValue == null || rawValue.isBlank()) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, "생년월일은 필수입니다."); + } + } + + private static LocalDate parseDate(String rawValue) { + try { + return LocalDate.parse(rawValue); + } catch (DateTimeParseException e) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, + "생년월일은 YYYY-MM-DD 형식이어야 합니다.", e); + } + } + + private static void validateRange(LocalDate date) { + if (date.isBefore(MIN_DATE)) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, + "생년월일은 " + MIN_DATE + " 이후여야 합니다."); + } + + if (date.isAfter(LocalDate.now())) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, + "생년월일은 미래 날짜일 수 없습니다."); + } + + if (Period.between(date, LocalDate.now()).getYears() < MIN_AGE) { + throw new CoreException(UserErrorType.INVALID_BIRTH_DATE, + "만 " + MIN_AGE + "세 이상만 가입할 수 있습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Email.java new file mode 100644 index 00000000..ba292170 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Email.java @@ -0,0 +1,54 @@ +package com.loopers.domain.user.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.regex.Pattern; + +/** + * 이메일 Value Object + * + * 검증 규칙: + * - 기본 이메일 형식 (local@domain) + * - 최대 255자 + */ +@Embeddable +public class Email { + + private static final int MAX_LENGTH = 255; + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + ); + + @Column(name = "email") + private String value; + + protected Email() {} + + public Email(String value) { + validate(value); + this.value = value; + } + + public String getValue() { + return value; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(UserErrorType.INVALID_EMAIL, "이메일은 필수입니다."); + } + + if (value.length() > MAX_LENGTH) { + throw new CoreException(UserErrorType.INVALID_EMAIL, + "이메일은 최대 " + MAX_LENGTH + "자까지 가능합니다."); + } + + if (!EMAIL_PATTERN.matcher(value).matches()) { + throw new CoreException(UserErrorType.INVALID_EMAIL, + "이메일 형식이 올바르지 않습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/LoginId.java new file mode 100644 index 00000000..8bc48336 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/LoginId.java @@ -0,0 +1,55 @@ +package com.loopers.domain.user.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.regex.Pattern; + +/** + * 로그인 ID Value Object + * + * 검증 규칙: + * - 영문 대소문자 + 숫자만 허용 + * - 4~20자 + * - 영문으로 시작 + */ +@Embeddable +public class LoginId { + + private static final int MIN_LENGTH = 4; + private static final int MAX_LENGTH = 20; + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9]{3,19}$"); + + @Column(name = "login_id", unique = true, nullable = false) + private String value; + + // JPA 기본 생성자 + protected LoginId() {} + + public LoginId(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(UserErrorType.INVALID_LOGIN_ID, "로그인 ID는 필수입니다."); + } + + if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { + throw new CoreException(UserErrorType.INVALID_LOGIN_ID, + "로그인 ID는 " + MIN_LENGTH + "~" + MAX_LENGTH + "자여야 합니다."); + } + + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(UserErrorType.INVALID_LOGIN_ID, + "로그인 ID는 영문으로 시작하고, 영문과 숫자만 사용할 수 있습니다."); + } + } + + public String getValue() { + return value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Password.java new file mode 100644 index 00000000..558117a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/Password.java @@ -0,0 +1,65 @@ +package com.loopers.domain.user.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; + +import java.util.regex.Pattern; + +/** + * 비밀번호 Value Object (검증 전용) + * + * raw 비밀번호의 자체 규칙만 검증한다. + * - 암호화는 Service 레이어에서 담당 + * - 교차 검증(생년월일 포함 금지)은 UserService에서 담당 + * + * 검증 규칙: + * - 8~16자 + * - 영문 대소문자, 숫자, 특수문자만 허용 + * - 영문 대문자/소문자/숫자/특수문자 중 3종류 이상 포함 + */ +public class Password { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final int MIN_COMPLEXITY = 3; + private static final Pattern ALLOWED_CHARS = Pattern.compile("^[!-~]+$"); + + private final String value; + + private Password(String value) { + this.value = value; + } + + public static Password of(String rawPassword) { + validate(rawPassword); + return new Password(rawPassword); + } + + public String getValue() { + return value; + } + + private static void validate(String rawPassword) { + if (rawPassword == null || rawPassword.isBlank()) { + throw new CoreException(UserErrorType.INVALID_PASSWORD); + } + + if (rawPassword.length() < MIN_LENGTH || rawPassword.length() > MAX_LENGTH) { + throw new CoreException(UserErrorType.INVALID_PASSWORD); + } + + if (!ALLOWED_CHARS.matcher(rawPassword).matches()) { + throw new CoreException(UserErrorType.INVALID_PASSWORD); + } + + int typeCount = 0; + if (rawPassword.chars().anyMatch(Character::isUpperCase)) typeCount++; + if (rawPassword.chars().anyMatch(Character::isLowerCase)) typeCount++; + if (rawPassword.chars().anyMatch(Character::isDigit)) typeCount++; + if (rawPassword.chars().anyMatch(c -> !Character.isLetterOrDigit(c))) typeCount++; + + if (typeCount < MIN_COMPLEXITY) { + throw new CoreException(UserErrorType.INVALID_PASSWORD); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/UserName.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/UserName.java new file mode 100644 index 00000000..52ef2c45 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/vo/UserName.java @@ -0,0 +1,61 @@ +package com.loopers.domain.user.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.regex.Pattern; + +/** + * 이름 Value Object + * + * 검증 규칙: + * - 한글, 영문만 허용 + * - 2~50자 + * - 공백 불허 + * + * 마스킹 규칙: + * - 마지막 1글자를 '*'로 대체 + */ +@Embeddable +public class UserName { + + private static final int MIN_LENGTH = 2; + private static final int MAX_LENGTH = 50; + private static final Pattern PATTERN = Pattern.compile("^[가-힣a-zA-Z]+$"); + + @Column(name = "name") + private String value; + + protected UserName() {} + + public UserName(String value) { + validate(value); + this.value = value; + } + + public String getValue() { + return value; + } + + public String getMaskedValue() { + return value.substring(0, value.length() - 1) + "*"; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(UserErrorType.INVALID_NAME, "이름은 필수입니다."); + } + + if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { + throw new CoreException(UserErrorType.INVALID_NAME, + "이름은 " + MIN_LENGTH + "자 이상 " + MAX_LENGTH + "자 이하여야 합니다."); + } + + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(UserErrorType.INVALID_NAME, + "이름은 한글과 영문만 사용할 수 있습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java new file mode 100644 index 00000000..91ad2c5f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncryptor.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.PasswordEncryptor; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +/** + * PasswordEncryptor 어댑터 (Infrastructure Layer) + * + * Spring Security의 BCryptPasswordEncoder를 사용하여 비밀번호를 암호화한다. + * BCryptPasswordEncoder는 null 입력 시 IllegalArgumentException을 발생시키므로, + * 도메인 예외(CoreException)로 일관된 에러 응답을 보장하기 위해 null을 선검증한다. + */ +@Component +public class BCryptPasswordEncryptor implements PasswordEncryptor { + + private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + + @Override + public String encode(String rawPassword) { + if (rawPassword == null || rawPassword.isBlank()) { + throw new CoreException(UserErrorType.INVALID_PASSWORD, "암호화할 비밀번호는 필수입니다."); + } + return this.encoder.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + if (rawPassword == null || rawPassword.isBlank()) { + throw new CoreException(UserErrorType.INVALID_PASSWORD, "비밀번호는 필수입니다."); + } + if (encodedPassword == null || encodedPassword.isBlank()) { + throw new CoreException(UserErrorType.INVALID_PASSWORD, "암호화된 비밀번호가 존재하지 않습니다."); + } + return this.encoder.matches(rawPassword, encodedPassword); + } +} \ No newline at end of file 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..3149f6c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * 사용자 JPA 리포지토리 + * + * Spring Data JPA가 제공하는 기본 CRUD와 쿼리 메서드를 정의한다. + * 도메인 계층의 {@link com.loopers.domain.user.UserRepository} 포트 구현체인 + * {@link UserRepositoryImpl}이 이 인터페이스에 위임하여 실제 DB 접근을 수행한다. + */ +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginIdValue(String loginId); + boolean existsByLoginIdValue(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..58e197b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * UserRepository 어댑터 (Infrastructure Layer) + * + * 도메인 포트(UserRepository)의 구현체로, Spring Data JPA에 위임한다. + */ +@Repository +public class UserRepositoryImpl implements UserRepository { + private final UserJpaRepository userJpaRepository; + + public UserRepositoryImpl(UserJpaRepository userJpaRepository) { + this.userJpaRepository = userJpaRepository; + } + + @Override + public User save(User user) { + return this.userJpaRepository.save(user); + } + + @Override + public Optional findByLoginId(String loginId) { + return this.userJpaRepository.findByLoginIdValue(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return this.userJpaRepository.existsByLoginIdValue(loginId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c..9dd8822d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -3,9 +3,11 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import com.loopers.support.error.CommonErrorType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MissingServletRequestParameterException; @@ -20,9 +22,20 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; +/** + * 전역 예외 핸들러 + * + * 에러 메시지 정책: + * - 클라이언트 응답: 사용자가 이해할 수 있는 메시지만 반환 (시스템 내부 정보 노출 금지) + * - 시스템 로그: 구체적인 요청 파라미터, 상태값, 스택트레이스를 기록 + * + * customMessage가 있으면 우선 사용하고, 없으면 ErrorType의 기본 메시지를 반환한다. + */ @RestControllerAdvice -@Slf4j public class ApiControllerAdvice { + + private static final Logger log = LoggerFactory.getLogger(ApiControllerAdvice.class); + @ExceptionHandler public ResponseEntity> handle(CoreException e) { log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e); @@ -35,7 +48,7 @@ public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatc String type = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "unknown"; String value = e.getValue() != null ? e.getValue().toString() : "null"; String message = String.format("요청 파라미터 '%s' (타입: %s)의 값 '%s'이(가) 잘못되었습니다.", name, type, value); - return failureResponse(ErrorType.BAD_REQUEST, message); + return failureResponse(CommonErrorType.BAD_REQUEST, message); } @ExceptionHandler @@ -43,7 +56,7 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara String name = e.getParameterName(); String type = e.getParameterType(); String message = String.format("필수 요청 파라미터 '%s' (타입: %s)가 누락되었습니다.", name, type); - return failureResponse(ErrorType.BAD_REQUEST, message); + return failureResponse(CommonErrorType.BAD_REQUEST, message); } @ExceptionHandler @@ -88,7 +101,7 @@ public ResponseEntity> handleBadRequest(HttpMessageNotReadableExc errorMessage = "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요."; } - return failureResponse(ErrorType.BAD_REQUEST, errorMessage); + return failureResponse(CommonErrorType.BAD_REQUEST, errorMessage); } @ExceptionHandler @@ -96,21 +109,21 @@ public ResponseEntity> handleBadRequest(ServerWebInputException e String missingParams = extractMissingParameter(e.getReason() != null ? e.getReason() : ""); if (!missingParams.isEmpty()) { String message = String.format("필수 요청 값 '%s'가 누락되었습니다.", missingParams); - return failureResponse(ErrorType.BAD_REQUEST, message); + return failureResponse(CommonErrorType.BAD_REQUEST, message); } else { - return failureResponse(ErrorType.BAD_REQUEST, null); + return failureResponse(CommonErrorType.BAD_REQUEST, null); } } @ExceptionHandler public ResponseEntity> handleNotFound(NoResourceFoundException e) { - return failureResponse(ErrorType.NOT_FOUND, null); + return failureResponse(CommonErrorType.NOT_FOUND, null); } @ExceptionHandler public ResponseEntity> handle(Throwable e) { log.error("Exception : {}", e.getMessage(), e); - return failureResponse(ErrorType.INTERNAL_ERROR, null); + return failureResponse(CommonErrorType.INTERNAL_ERROR, null); } private String extractMissingParameter(String message) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java index 33b77b52..68e7c49a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java @@ -1,5 +1,12 @@ package com.loopers.interfaces.api; +/** + * 표준 API 응답 래퍼 + * + * 모든 API 응답은 이 포맷으로 통일한다. + * - meta: 결과 상태(SUCCESS/FAIL), 에러 코드, 에러 메시지 + * - data: 응답 본문 (실패 시 null) + */ public record ApiResponse(Metadata meta, T data) { public record Metadata(Result result, String errorCode, String message) { public enum Result { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 91737601..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java new file mode 100644 index 00000000..41d70a7f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.domain.user.User; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * 사용자 API 스펙 인터페이스 + * + * Swagger(OpenAPI) 문서화 어노테이션과 Controller 메서드 시그니처를 분리하여 + * Controller 구현체의 가독성을 높이고, API 계약을 명시적으로 정의한다. + */ +@Tag(name = "User API", description = "사용자 관련 API") +public interface UserApiSpec { + + @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") + ApiResponse createUser(UserRequest.SignupRequest request); + + @Operation(summary = "내 정보 조회", description = "인증된 사용자의 정보를 조회합니다.") + ApiResponse getUser(User user); + + @Operation(summary = "비밀번호 변경", description = "인증된 사용자의 비밀번호를 변경합니다.") + ApiResponse updateUserPassword(User user, UserRequest.ChangePasswordRequest request); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java new file mode 100644 index 00000000..896eb66f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthUser; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +/** + * 사용자 API 컨트롤러 + */ +@RestController +@RequestMapping("/api/v1/users") +public class UserController implements UserApiSpec { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createUser(@RequestBody UserRequest.SignupRequest request) { + User user = this.userService.createUser( + request.loginId(), request.password(), request.name(), request.birthDate(), request.email() + ); + UserInfo info = UserInfo.from(user); + return ApiResponse.success(UserResponse.SignupResponse.from(info)); + } + + @GetMapping("/me") + @Override + public ApiResponse getUser(@AuthUser User user) { + return ApiResponse.success(UserResponse.UserDetailResponse.from(UserInfo.from(user))); + } + + @PutMapping("/password") + @Override + public ApiResponse updateUserPassword( + @AuthUser User user, + @RequestBody UserRequest.ChangePasswordRequest request + ) { + this.userService.updateUserPassword(user, request.currentPassword(), request.newPassword()); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserRequest.java new file mode 100644 index 00000000..05bc3145 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserRequest.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.user; + +/** 사용자 API 요청 DTO */ +public class UserRequest { + + public record SignupRequest( + String loginId, + String password, + String name, + String birthDate, + String email + ) { + /** 비밀번호 평문 노출 방지 */ + @Override + public String toString() { + return "SignupRequest[loginId=" + loginId + ", password=*****, name=" + name + + ", birthDate=" + birthDate + ", email=" + email + "]"; + } + } + + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) { + /** 비밀번호 평문 노출 방지 */ + @Override + public String toString() { + return "ChangePasswordRequest[currentPassword=*****, newPassword=*****]"; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserResponse.java new file mode 100644 index 00000000..0cb032d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserResponse.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; + +import java.time.LocalDate; + +/** 사용자 API 응답 DTO */ +public class UserResponse { + public record SignupResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static SignupResponse from(UserInfo info) { + return new SignupResponse(info.loginId(), info.name(), info.birthDate(), info.email()); + } + } + + /** 이름은 개인정보 보호를 위해 maskedName으로 반환한다 */ + public record UserDetailResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static UserDetailResponse from(UserInfo info) { + return new UserDetailResponse(info.loginId(), info.maskedName(), info.birthDate(), info.email()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java new file mode 100644 index 00000000..daf11b98 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUser.java @@ -0,0 +1,17 @@ +package com.loopers.support.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 인증된 사용자 주입 어노테이션 + * + * Controller 메서드 파라미터에 선언하면 {@link AuthUserResolver}가 + * 요청 헤더의 인증 정보를 기반으로 User 객체를 주입한다. + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthUser { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUserResolver.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUserResolver.java new file mode 100644 index 00000000..9e471c60 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUserResolver.java @@ -0,0 +1,56 @@ +package com.loopers.support.auth; + +import com.loopers.domain.user.UserService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; + +/** + * 인증 Argument Resolver + * + * @AuthUser가 붙은 Controller 파라미터에 인증된 User를 주입한다. + * 헤더(X-Loopers-LoginId/Pw)를 추출하고, 인증은 UserService에 위임한다. + */ +@Component +public class AuthUserResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final UserService userService; + + public AuthUserResolver(UserService userService) { + this.userService = userService; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthUser.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + + String loginId = request.getHeader(HEADER_LOGIN_ID); + String password = request.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank() || password == null || password.isBlank()) { + throw new CoreException(UserErrorType.UNAUTHORIZED, "인증 헤더가 필요합니다."); + } + + return this.userService.authenticateUser(loginId, password); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java new file mode 100644 index 00000000..cbcac1d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CommonErrorType.java @@ -0,0 +1,40 @@ +package com.loopers.support.error; + +import org.springframework.http.HttpStatus; + +/** + * 공통 에러 타입 + * + * 특정 도메인에 속하지 않는 범용 HTTP 에러를 정의한다. + * 도메인별 에러 타입(예: {@link UserErrorType})과 분리하여 관리한다. + */ +public enum CommonErrorType implements ErrorType { + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "일시적인 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 요청입니다."), + CONFLICT(HttpStatus.CONFLICT, "이미 존재하는 리소스입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증에 실패했습니다."); + + private final HttpStatus status; + private final String message; + + CommonErrorType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getCode() { + return name(); + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java index 0cc190b6..cf79738b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java @@ -1,19 +1,35 @@ package com.loopers.support.error; -import lombok.Getter; - -@Getter +/** + * 비즈니스 예외 + * + * customMessage가 있으면 클라이언트에 해당 메시지를 반환하고, + * 없으면 ErrorType의 기본 메시지를 반환한다. + * cause를 통해 원본 예외 체인을 보존하여 로그에서 추적 가능하게 한다. + */ public class CoreException extends RuntimeException { private final ErrorType errorType; private final String customMessage; public CoreException(ErrorType errorType) { - this(errorType, null); + this(errorType, null, null); } public CoreException(ErrorType errorType, String customMessage) { - super(customMessage != null ? customMessage : errorType.getMessage()); + this(errorType, customMessage, null); + } + + public CoreException(ErrorType errorType, String customMessage, Throwable cause) { + super(customMessage != null ? customMessage : errorType.getMessage(), cause); this.errorType = errorType; this.customMessage = customMessage; } + + public ErrorType getErrorType() { + return this.errorType; + } + + public String getCustomMessage() { + return this.customMessage; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efb..19f1e95a 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 @@ -1,19 +1,15 @@ package com.loopers.support.error; -import lombok.Getter; -import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; -@Getter -@RequiredArgsConstructor -public enum ErrorType { - /** 범용 에러 */ - INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), - BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), - NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); - - private final HttpStatus status; - private final String code; - private final String message; -} +/** + * 에러 타입 인터페이스 + * + * 모든 에러 열거형(UserErrorType, CommonErrorType 등)이 구현하는 공통 계약. + * getCode()는 enum의 name()을 반환하여 별도 코드 관리 없이 식별한다. + */ +public interface ErrorType { + HttpStatus getStatus(); + String getCode(); + String getMessage(); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java new file mode 100644 index 00000000..a9186ea4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/UserErrorType.java @@ -0,0 +1,56 @@ +package com.loopers.support.error; + +import org.springframework.http.HttpStatus; + +/** + * 사용자 도메인 에러 타입 + * + * 사용자 관련 비즈니스 에러를 HTTP 상태 코드별로 그룹핑하여 정의한다. + * - 400: 입력값 형식/정책 위반 (로그인 ID, 비밀번호, 이름, 생년월일, 이메일) + * - 401: 인증 실패, 비밀번호 불일치 + * - 404: 사용자 미존재 + * - 409: 중복 리소스 (로그인 ID) + */ +public enum UserErrorType implements ErrorType { + // 400 Bad Request + INVALID_LOGIN_ID(HttpStatus.BAD_REQUEST, "로그인 ID 형식이 올바르지 않습니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "비밀번호 형식이 올바르지 않습니다."), + INVALID_NAME(HttpStatus.BAD_REQUEST, "이름 형식이 올바르지 않습니다."), + INVALID_BIRTH_DATE(HttpStatus.BAD_REQUEST, "생년월일 형식이 올바르지 않습니다."), + INVALID_EMAIL(HttpStatus.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."), + SAME_PASSWORD(HttpStatus.BAD_REQUEST, "현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다."), + PASSWORD_CONTAINS_BIRTH_DATE(HttpStatus.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."), + + // 401 Unauthorized + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증에 실패했습니다."), + PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), + + // 404 Not Found + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), + + // 409 Conflict + DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "이미 사용 중인 로그인 ID입니다."); + + private final HttpStatus status; + private final String message; + + UserErrorType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getCode() { + return name(); + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -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.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -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.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java new file mode 100644 index 00000000..522d0a95 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java @@ -0,0 +1,180 @@ +package com.loopers.domain.user; + +import com.loopers.domain.user.vo.BirthDate; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * BirthDate Value Object 단위 테스트 + * + * 검증 규칙: + * - YYYY-MM-DD 형식 (ISO 8601) + * - 1900-01-01 ~ 현재 날짜 + * - 실제 존재하는 날짜 + * - 만 14세 이상 + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public class BirthDateTest { + + @DisplayName("생년월일을 생성할 때,") + @Nested + class Create { + + // ========== 정상 케이스 ========== + + @Test + void 유효한_날짜이면_정상적으로_생성된다() { + // arrange + String value = "1994-11-15"; + + // act + BirthDate birthDate = new BirthDate(value); + + // assert + assertThat(birthDate.getValue()).isEqualTo(LocalDate.of(1994, 11, 15)); + } + + @Test + void 윤년_2월_29일이면_정상적으로_생성된다() { + // arrange + String value = "2000-02-29"; + + // act + BirthDate birthDate = new BirthDate(value); + + // assert + assertThat(birthDate.getValue()).isEqualTo(LocalDate.of(2000, 2, 29)); + } + + @Test + void 최소_허용_날짜이면_정상적으로_생성된다() { + // arrange + String value = "1900-01-01"; + + // act + BirthDate birthDate = new BirthDate(value); + + // assert + assertThat(birthDate.getValue()).isEqualTo(LocalDate.of(1900, 1, 1)); + } + + @Test + void 만_14세_경계이면_정상적으로_생성된다() { + // arrange + String value = LocalDate.now().minusYears(14).format(DateTimeFormatter.ISO_LOCAL_DATE); + + // act + BirthDate birthDate = new BirthDate(value); + + // assert + assertThat(birthDate.getValue()).isEqualTo(LocalDate.now().minusYears(14)); + } + + // ========== 엣지 케이스 ========== + + @Test + void null이면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate(null); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); + } + + @Test + void 잘못된_형식_슬래시이면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("1994/11/15"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); + } + + @Test + void 잘못된_형식_구분자_없음이면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("19941115"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); + } + + @Test + void 존재하지_않는_날짜이면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("1994-02-30"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); + } + + @Test + void 미래_날짜이면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("2030-01-01"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); + } + + @Test + void 날짜가_1900년_이전이면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("1899-12-31"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); + } + + @Test + void 만_14세_미만이면_예외가_발생한다() { + // arrange + String value = LocalDate.now().minusYears(13).format(DateTimeFormatter.ISO_LOCAL_DATE); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate(value); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); + } + + @Test + void 비윤년_2월_29일이면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("1999-02-29"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_BIRTH_DATE); + } + + @Test + void 잘못된_형식이면_예외의_원인으로_DateTimeParseException을_포함한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new BirthDate("1994/11/15"); + }); + + assertThat(exception.getCause()).isInstanceOf(DateTimeParseException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java new file mode 100644 index 00000000..5227fa6a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java @@ -0,0 +1,89 @@ +package com.loopers.domain.user; + +import com.loopers.domain.user.vo.Email; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +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; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public class EmailTest { + + @DisplayName("이메일을 생성할 때,") + @Nested + class Create { + + @Test + void 유효한_이메일이면_정상적으로_생성된다() { + Email email = new Email("nahyeon@example.com"); + assertThat(email.getValue()).isEqualTo("nahyeon@example.com"); + } + + @Test + void 서브도메인이_있으면_정상적으로_생성된다() { + Email email = new Email("nahyeon@mail.example.com"); + assertThat(email.getValue()).isEqualTo("nahyeon@mail.example.com"); + } + + @Test + void 플러스_기호가_포함되면_정상적으로_생성된다() { + Email email = new Email("nahyeon+tag@example.com"); + assertThat(email.getValue()).isEqualTo("nahyeon+tag@example.com"); + } + + @Test + void null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> new Email(null)); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); + } + + @Test + void 빈_문자열이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> new Email("")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); + } + + @Test + void 골뱅이가_없으면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> new Email("nahyeonexample.com")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); + } + + @Test + void 도메인이_없으면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> new Email("nahyeon@")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); + } + + @Test + void 로컬_파트가_없으면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> new Email("@example.com")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); + } + + @Test + void 공백이_포함되면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> new Email("nahyeon lim@example.com")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); + } + + @Test + void 길이가_256자를_초과하면_예외가_발생한다() { + String value = "a".repeat(250) + "@b.com"; + CoreException exception = assertThrows(CoreException.class, () -> new Email(value)); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); + } + + @Test + void 한글이_포함되면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> new Email("홍길동@example.com")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_EMAIL); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java new file mode 100644 index 00000000..194d15cc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java @@ -0,0 +1,163 @@ +package com.loopers.domain.user; + +import com.loopers.domain.user.vo.LoginId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +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; + +/** + * LoginId Value Object 단위 테스트 + * + * 검증 규칙: + * - 영문 대소문자 + 숫자만 허용 + * - 4~20자 + * - 영문으로 시작 + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public class LoginIdTest { + + @DisplayName("로그인 ID를 생성할 때,") + @Nested + class Create { + + // ========== 정상 케이스 ========== + + @Test + void 최소_길이_4자_영문이면_정상적으로_생성된다() { + // arrange + String value = "nahyeon"; + + // act + LoginId loginId = new LoginId(value); + + // assert + assertThat(loginId.getValue()).isEqualTo(value); + } + + @Test + void 최대_길이_20자이면_정상적으로_생성된다() { + // arrange + String value = "abcdefghij1234567890"; // 20자 + + // act + LoginId loginId = new LoginId(value); + + // assert + assertThat(loginId.getValue()).isEqualTo(value); + } + + @Test + void 영문_대소문자_숫자_조합이면_정상적으로_생성된다() { + // arrange + String value = "nahyeon123"; + + // act + LoginId loginId = new LoginId(value); + + // assert + assertThat(loginId.getValue()).isEqualTo(value); + } + + // ========== 엣지 케이스 ========== + + @Test + void null이면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId(null); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); + } + + @Test + void 빈_문자열이면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId(""); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); + } + + @Test + void 공백만_있으면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId(" "); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); + } + + @Test + void 길이가_3자_최소_미만이면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId("abc"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); + } + + @Test + void 길이가_21자_최대_초과이면_예외가_발생한다() { + // arrange + String value = "abcdefghij12345678901"; // 21자 + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId(value); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); + } + + @Test + void 특수문자가_포함되면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId("nahyeon@123"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); + } + + @Test + void 한글이_포함되면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId("nahyeon홍"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); + } + + @Test + void 숫자로_시작하면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId("123nahyeon"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); + } + + @Test + void 공백이_포함되면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new LoginId("nahyeon Lim"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_LOGIN_ID); + } + } +} 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..7723ee82 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -0,0 +1,100 @@ +package com.loopers.domain.user; + +import com.loopers.domain.user.vo.Password; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +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; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public class PasswordTest { + + @DisplayName("비밀번호를 생성할 때,") + @Nested + class Create { + + @Test + void 모든_규칙을_만족하면_정상적으로_생성된다() { + Password password = Password.of("Hx7!mK2@"); + assertThat(password.getValue()).isEqualTo("Hx7!mK2@"); + } + + @Test + void 최소_길이_8자이면_정상적으로_생성된다() { + Password password = Password.of("Xz5!qw9@"); + assertThat(password.getValue()).isEqualTo("Xz5!qw9@"); + } + + @Test + void 최대_길이_16자이면_정상적으로_생성된다() { + Password password = Password.of("Px8!Kd3@Wm7#Rf2$"); + assertThat(password.getValue()).isEqualTo("Px8!Kd3@Wm7#Rf2$"); + } + + @Test + void 다양한_특수문자_조합이면_정상적으로_생성된다() { + assertDoesNotThrow(() -> Password.of("Ac1~`[]{}")); + } + + @Test + void null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> Password.of(null)); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 빈_문자열이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> Password.of("")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 길이가_7자_최소_미만이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> Password.of("Abcd12!")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 길이가_17자_최대_초과이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> Password.of("Px8!Kd3@Wm7#Rf2$A")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 영문만_있으면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> Password.of("Abcdefgh")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 숫자만_있으면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> Password.of("12345978")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 특수문자만_있으면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> Password.of("!@#$%^&*")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 한글이_포함되면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> Password.of("Abcd123가")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 공백이_포함되면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> Password.of("Abcd 12!")); + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java new file mode 100644 index 00000000..81b7a4f6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java @@ -0,0 +1,211 @@ +package com.loopers.domain.user; + +import com.loopers.domain.user.vo.UserName; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +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; + +/** + * UserName Value Object 단위 테스트 + * + * 검증 규칙: + * - 한글, 영문만 허용 + * - 2~50자 + * - 공백 불허 + * + * 마스킹 규칙: + * - 마지막 1글자를 '*'로 대체 + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public class UserNameTest { + + @DisplayName("이름을 생성할 때,") + @Nested + class Create { + + // ========== 정상 케이스 ========== + + @Test + void 한글_이름이면_정상적으로_생성된다() { + // arrange + String value = "홍길동"; + + // act + UserName userName = new UserName(value); + + // assert + assertThat(userName.getValue()).isEqualTo(value); + } + + @Test + void 영문_이름이면_정상적으로_생성된다() { + // arrange + String value = "Nahyeon"; + + // act + UserName userName = new UserName(value); + + // assert + assertThat(userName.getValue()).isEqualTo(value); + } + + @Test + void 최소_길이_2자이면_정상적으로_생성된다() { + // arrange + String value = "홍길"; + + // act + UserName userName = new UserName(value); + + // assert + assertThat(userName.getValue()).isEqualTo(value); + } + + @Test + void 최대_길이_50자이면_정상적으로_생성된다() { + // arrange + String value = "가".repeat(50); + + // act + UserName userName = new UserName(value); + + // assert + assertThat(userName.getValue()).isEqualTo(value); + } + + @Test + void 한글_영문_혼합이면_정상적으로_생성된다() { + // arrange + String value = "홍Nahyeon"; + + // act + UserName userName = new UserName(value); + + // assert + assertThat(userName.getValue()).isEqualTo(value); + } + + // ========== 엣지 케이스 ========== + + @Test + void null이면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName(null); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); + } + + @Test + void 빈_문자열이면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName(""); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); + } + + @Test + void 길이가_1자_최소_미만이면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName("홍"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); + } + + @Test + void 길이가_51자_최대_초과이면_예외가_발생한다() { + // arrange + String value = "가".repeat(51); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName(value); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); + } + + @Test + void 숫자가_포함되면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName("홍길동123"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); + } + + @Test + void 특수문자가_포함되면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName("홍길동!"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); + } + + @Test + void 공백이_포함되면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + new UserName("홍 길동"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_NAME); + } + } + + @DisplayName("이름을 마스킹할 때,") + @Nested + class Masking { + + @Test + void 이름이_3자_한글이면_마지막_글자가_별표로_대체된다() { + // arrange + UserName userName = new UserName("홍길동"); + + // act + String masked = userName.getMaskedValue(); + + // assert + assertThat(masked).isEqualTo("홍길*"); + } + + @Test + void 이름이_2자_한글이면_마지막_글자가_별표로_대체된다() { + // arrange + UserName userName = new UserName("홍길"); + + // act + String masked = userName.getMaskedValue(); + + // assert + assertThat(masked).isEqualTo("홍*"); + } + + @Test + void 영문이면_마지막_글자가_별표로_대체된다() { + // arrange + UserName userName = new UserName("Nahyeon"); + + // act + String masked = userName.getMaskedValue(); + + // assert + assertThat(masked).isEqualTo("Nahyeo*"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java new file mode 100644 index 00000000..f65c44e2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserRepositoryIntegrationTest.java @@ -0,0 +1,167 @@ +package com.loopers.domain.user; + +import com.loopers.domain.user.vo.BirthDate; +import com.loopers.domain.user.vo.Email; +import com.loopers.domain.user.vo.LoginId; +import com.loopers.domain.user.vo.UserName; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * [Infrastructure Layer - Repository 통합 테스트] + * + * UserRepository 인터페이스의 DB 연동을 검증하는 통합 테스트. + * Spring Context를 로드하고 Testcontainers로 실제 MySQL을 사용한다. + * + * 테스트 범위: + * - UserRepository (interface) → UserRepositoryImpl → UserJpaRepository → DB + * - 실제 쿼리 실행 및 데이터 저장/조회 검증 + * + * 테스트 격리: + * - @AfterEach에서 truncateAllTables() 호출 + * - 각 테스트가 독립적으로 실행될 수 있도록 보장 + */ +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserRepositoryIntegrationTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + /** + * 테스트용 User 엔티티 생성 헬퍼 메서드 + */ + private User createTestUser(String loginIdValue) { + return User.create( + new LoginId(loginIdValue), + "$2a$10$encodedPasswordHash", + new UserName("홍길동"), + new BirthDate("1994-11-15"), + new Email(loginIdValue + "@example.com") + ); + } + + @Nested + @DisplayName("save 메서드는") + class Save { + + @Test + void 새로운_사용자를_저장하면_ID가_생성된다() { + // arrange + User user = createTestUser("testuser"); + + // act + User savedUser = userRepository.save(user); + + // assert + assertThat(savedUser.getId()).isNotNull(); + } + + @Test + void 저장된_사용자의_모든_필드가_올바르게_저장된다() { + // arrange + LoginId loginId = new LoginId("nahyeon"); + String encodedPassword = "$2a$10$encodedPasswordHash"; + UserName name = new UserName("홍길동"); + BirthDate birthDate = new BirthDate("1994-11-15"); + Email email = new Email("nahyeon@example.com"); + + User user = User.create(loginId, encodedPassword, name, birthDate, email); + + // act + User savedUser = userRepository.save(user); + + // assert + assertAll( + () -> assertThat(savedUser.getLoginId().getValue()).isEqualTo("nahyeon"), + () -> assertThat(savedUser.getPassword()).isEqualTo(encodedPassword), + () -> assertThat(savedUser.getName().getValue()).isEqualTo("홍길동"), + () -> assertThat(savedUser.getBirthDate().getValue()).isEqualTo(birthDate.getValue()), + () -> assertThat(savedUser.getEmail().getValue()).isEqualTo("nahyeon@example.com") + ); + } + } + + @Nested + @DisplayName("findByLoginId 메서드는") + class FindByLoginId { + + @Test + void 존재하는_로그인ID로_조회하면_해당_사용자를_반환한다() { + // arrange + User user = createTestUser("existinguser"); + userRepository.save(user); + + // act + Optional result = userRepository.findByLoginId("existinguser"); + + // assert + assertAll( + () -> assertThat(result).isPresent(), + () -> assertThat(result.get().getLoginId().getValue()).isEqualTo("existinguser") + ); + } + + @Test + void 존재하지_않는_로그인ID로_조회하면_empty를_반환한다() { + // arrange + String nonExistingLoginId = "nonexisting"; + + // act + Optional result = userRepository.findByLoginId(nonExistingLoginId); + + // assert + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("existsByLoginId 메서드는") + class ExistsByLoginId { + + @Test + void 존재하는_로그인ID면_true를_반환한다() { + // arrange + User user = createTestUser("existinguser"); + userRepository.save(user); + + // act + boolean exists = userRepository.existsByLoginId("existinguser"); + + // assert + assertThat(exists).isTrue(); + } + + @Test + void 존재하지_않는_로그인ID면_false를_반환한다() { + // arrange + String nonExistingLoginId = "nonexisting"; + + // act + boolean exists = userRepository.existsByLoginId(nonExistingLoginId); + + // assert + assertThat(exists).isFalse(); + } + } +} 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..a73dbf9e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,151 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +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.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * UserService 통합 테스트 + * + * 실제 DB(Testcontainers)를 사용하여 UserService의 비즈니스 흐름을 검증한다. + * + * 테스트 범위: + * - UserService → UserRepository → DB + * - 실제 비밀번호 암호화 + DB 영속화 + */ +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncryptor passwordEncryptor; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("회원가입 시,") + class Signup { + + @Test + void 유효한_정보면_DB에_저장된다() { + // act + User user = userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + + // assert + assertAll( + () -> assertThat(user.getId()).isNotNull(), + () -> assertThat(userRepository.existsByLoginId("nahyeon")).isTrue() + ); + } + + @Test + void 비밀번호가_BCrypt로_암호화되어_저장된다() { + // act + userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + + // assert + User saved = userRepository.findByLoginId("nahyeon").orElseThrow(); + assertAll( + () -> assertThat(saved.getPassword()).startsWith("$2a$"), + () -> assertThat(passwordEncryptor.matches("Hx7!mK2@", saved.getPassword())).isTrue() + ); + } + + @Test + void 중복된_로그인ID면_예외가_발생한다() { + // arrange + userService.createUser("duplicate", "Hx7!mK2@", "홍길동", "1994-11-15", "first@example.com"); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.createUser("duplicate", "Nw8@pL3#", "김철수", "1995-05-05", "second@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); + } + + @Test + void 비밀번호에_생년월일이_포함되면_예외가_발생한다() { + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.createUser("nahyeon", "X19940115!", "홍길동", "1994-01-15", "nahyeon@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + } + } + + @Nested + @DisplayName("비밀번호 변경 시,") + class ChangePassword { + + @Test + void 유효한_요청이면_DB에_새_비밀번호가_반영된다() { + // arrange + userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + User user = userService.authenticateUser("nahyeon", "Hx7!mK2@"); + + // act + userService.updateUserPassword(user, "Hx7!mK2@", "Nw8@pL3#"); + + // assert + User reloaded = userRepository.findByLoginId("nahyeon").orElseThrow(); + assertAll( + () -> assertThat(passwordEncryptor.matches("Nw8@pL3#", reloaded.getPassword())).isTrue(), + () -> assertThat(passwordEncryptor.matches("Hx7!mK2@", reloaded.getPassword())).isFalse() + ); + } + + @Test + void 현재_비밀번호가_틀리면_예외가_발생한다() { + // arrange + userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + User user = userService.authenticateUser("nahyeon", "Hx7!mK2@"); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(user, "wrongPw1!", "Nw8@pL3#"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_MISMATCH); + } + + @Test + void 새_비밀번호가_현재와_동일하면_예외가_발생한다() { + // arrange + userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + User user = userService.authenticateUser("nahyeon", "Hx7!mK2@"); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(user, "Hx7!mK2@", "Hx7!mK2@"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.SAME_PASSWORD); + } + } +} 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..39834c27 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -0,0 +1,363 @@ +package com.loopers.domain.user; + +import com.loopers.domain.user.vo.BirthDate; +import com.loopers.domain.user.vo.Email; +import com.loopers.domain.user.vo.LoginId; +import com.loopers.domain.user.vo.UserName; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Optional; + + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * UserService 단위 테스트 + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public class UserServiceTest { + + private UserRepository userRepository; + private PasswordEncryptor passwordEncryptor; + private UserService userService; + + @BeforeEach + void setUp() { + userRepository = Mockito.mock(UserRepository.class); + passwordEncryptor = Mockito.mock(PasswordEncryptor.class); + userService = new UserService(userRepository, passwordEncryptor); + } + + @DisplayName("회원가입 시,") + @Nested + class Signup { + + @Test + void 유효한_정보면_정상적으로_가입된다() { + // arrange + when(userRepository.existsByLoginId(anyString())).thenReturn(false); + when(passwordEncryptor.encode(anyString())).thenReturn("$2a$10$encodedHash"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // act + User user = userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + + // assert - 데이터 검증 + assertAll( + () -> assertThat(user.getLoginId().getValue()).isEqualTo("nahyeon"), + () -> assertThat(user.getPassword()).isEqualTo("$2a$10$encodedHash"), + () -> assertThat(user.getName().getValue()).isEqualTo("홍길동"), + () -> assertThat(user.getBirthDate().getValue()).isEqualTo(java.time.LocalDate.of(1994, 11, 15)), + () -> assertThat(user.getEmail().getValue()).isEqualTo("nahyeon@example.com") + ); + + // assert - 행위 검증 (test double) + verify(userRepository).existsByLoginId("nahyeon"); + verify(passwordEncryptor).encode("Hx7!mK2@"); + verify(userRepository).save(any(User.class)); + } + + @Test + void 이미_존재하는_로그인_ID면_예외가_발생한다() { + // arrange + when(userRepository.existsByLoginId(anyString())).thenReturn(true); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.createUser("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.DUPLICATE_LOGIN_ID); + + // 중복 시 save가 호출되지 않아야 함 + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void 비밀번호에_생년월일이_포함되면_예외가_발생한다() { + // arrange + when(userRepository.existsByLoginId(anyString())).thenReturn(false); + + // act & assert - birthDate: 1990-03-25, password contains "19900325" + CoreException exception = assertThrows(CoreException.class, () -> { + userService.createUser("nahyeon", "X19900325!", "홍길동", "1990-03-25", "nahyeon@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + + // 검증 실패 시 암호화·저장이 호출되지 않아야 함 + verify(passwordEncryptor, never()).encode(anyString()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void 비밀번호에_생년월일_YYMMDD가_포함되면_예외가_발생한다() { + // arrange + when(userRepository.existsByLoginId(anyString())).thenReturn(false); + + // act & assert - birthDate: 1990-03-25, password contains "900325" (YYMMDD) + CoreException exception = assertThrows(CoreException.class, () -> { + userService.createUser("nahyeon", "Abc900325!", "홍길동", "1990-03-25", "nahyeon@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void 비밀번호에_생년월일_MMDD가_포함되면_예외가_발생한다() { + // arrange + when(userRepository.existsByLoginId(anyString())).thenReturn(false); + + // act & assert - birthDate: 1990-03-25, password contains "0325" (MMDD) + CoreException exception = assertThrows(CoreException.class, () -> { + userService.createUser("nahyeon", "Abc!0325xY", "홍길동", "1990-03-25", "nahyeon@example.com"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + verify(userRepository, never()).save(any(User.class)); + } + } + + @DisplayName("인증 시,") + @Nested + class Authenticate { + + @Test + void 유효한_ID_PW면_사용자를_반환한다() { + // arrange + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), new BirthDate("1994-11-15"), + new Email("nahyeon@example.com") + ); + when(userRepository.findByLoginId("nahyeon")).thenReturn(Optional.of(user)); + when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); + + // act + User result = userService.authenticateUser("nahyeon", "Hx7!mK2@"); + + // assert - 데이터 검증 + assertThat(result.getLoginId().getValue()).isEqualTo("nahyeon"); + + // assert - 행위 검증 + verify(userRepository).findByLoginId("nahyeon"); + verify(passwordEncryptor).matches("Hx7!mK2@", "$2a$10$hash"); + } + + @Test + void 존재하지_않는_ID면_예외가_발생한다() { + // arrange + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.empty()); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.authenticateUser("unknown", "Hx7!mK2@"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + + // 유저 조회 실패 시 비밀번호 비교가 호출되지 않아야 함 + verify(passwordEncryptor, never()).matches(anyString(), anyString()); + } + + @Test + void 로그인_ID가_null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + userService.authenticateUser(null, "Hx7!mK2@"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + + @Test + void 로그인_ID가_빈_문자열이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + userService.authenticateUser(" ", "Hx7!mK2@"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + + @Test + void 비밀번호가_null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + userService.authenticateUser("nahyeon", null); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + + @Test + void 비밀번호가_빈_문자열이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + userService.authenticateUser("nahyeon", " "); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + + @Test + void 비밀번호가_불일치하면_예외가_발생한다() { + // arrange + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), new BirthDate("1994-11-15"), + new Email("nahyeon@example.com") + ); + when(userRepository.findByLoginId("nahyeon")).thenReturn(Optional.of(user)); + when(passwordEncryptor.matches(anyString(), anyString())).thenReturn(false); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.authenticateUser("nahyeon", "wrongPw1!"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.UNAUTHORIZED); + } + } + + @DisplayName("비밀번호 변경 시,") + @Nested + class ChangePassword { + + @Test + void 유효한_요청이면_비밀번호가_변경되고_영속화된다() { + // arrange + User user = User.create( + new LoginId("nahyeon"), "$2a$10$oldHash", + new UserName("홍길동"), new BirthDate("1994-11-15"), + new Email("nahyeon@example.com") + ); + when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$oldHash")).thenReturn(true); + when(passwordEncryptor.matches("Nw8@pL3#", "$2a$10$oldHash")).thenReturn(false); + when(passwordEncryptor.encode("Nw8@pL3#")).thenReturn("$2a$10$newHash"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // act + userService.updateUserPassword(user, "Hx7!mK2@", "Nw8@pL3#"); + + // assert - 데이터 검증 + assertThat(user.getPassword()).isEqualTo("$2a$10$newHash"); + + // assert - 행위 검증 (test double) + verify(passwordEncryptor).matches("Hx7!mK2@", "$2a$10$oldHash"); + verify(passwordEncryptor).encode("Nw8@pL3#"); + verify(userRepository).save(user); + } + + @Test + void 사용자가_null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(null, "Hx7!mK2@", "Nw8@pL3#"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.USER_NOT_FOUND); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void 현재_비밀번호가_null이면_예외가_발생한다() { + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), new BirthDate("1994-11-15"), + new Email("nahyeon@example.com") + ); + + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(user, null, "Nw8@pL3#"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void 새_비밀번호가_null이면_예외가_발생한다() { + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), new BirthDate("1994-11-15"), + new Email("nahyeon@example.com") + ); + + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(user, "Hx7!mK2@", null); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void 현재_비밀번호가_틀리면_예외가_발생한다() { + // arrange + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), new BirthDate("1994-11-15"), + new Email("nahyeon@example.com") + ); + when(passwordEncryptor.matches(anyString(), anyString())).thenReturn(false); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(user, "wrongPw!", "Nw8@pL3#"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_MISMATCH); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void 새_비밀번호가_현재와_동일하면_예외가_발생한다() { + // arrange + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), new BirthDate("1994-11-15"), + new Email("nahyeon@example.com") + ); + when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(user, "Hx7!mK2@", "Hx7!mK2@"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.SAME_PASSWORD); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void 새_비밀번호에_생년월일이_포함되면_예외가_발생한다() { + // arrange - birthDate: 1990-03-25 + User user = User.create( + new LoginId("nahyeon"), "$2a$10$hash", + new UserName("홍길동"), new BirthDate("1990-03-25"), + new Email("nahyeon@example.com") + ); + when(passwordEncryptor.matches("Hx7!mK2@", "$2a$10$hash")).thenReturn(true); + + // act & assert - newPassword contains "19900325" (YYYYMMDD) + CoreException exception = assertThrows(CoreException.class, () -> { + userService.updateUserPassword(user, "Hx7!mK2@", "X19900325!"); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.PASSWORD_CONTAINS_BIRTH_DATE); + verify(userRepository, never()).save(any(User.class)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java new file mode 100644 index 00000000..44fe2215 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.user; + +import com.loopers.domain.user.vo.BirthDate; +import com.loopers.domain.user.vo.Email; +import com.loopers.domain.user.vo.LoginId; +import com.loopers.domain.user.vo.UserName; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +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.assertAll; + +/** + * User Entity 단위 테스트 + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public class UserTest { + + @DisplayName("User를 생성할 때,") + @Nested + class Create { + + @Test + void 유효한_정보를_전달하면_정상적으로_생성된다() { + // arrange + LoginId loginId = new LoginId("nahyeon"); + String encodedPassword = "$2a$10$encodedPasswordHash"; + UserName name = new UserName("홍길동"); + BirthDate birthDate = new BirthDate("1994-11-15"); + Email email = new Email("nahyeon@example.com"); + + // act + User user = User.create(loginId, encodedPassword, name, birthDate, email); + + // assert + assertAll( + () -> assertThat(user.getLoginId().getValue()).isEqualTo("nahyeon"), + () -> assertThat(user.getPassword()).isEqualTo(encodedPassword), + () -> assertThat(user.getName().getValue()).isEqualTo("홍길동"), + () -> assertThat(user.getBirthDate().getValue()).isEqualTo(birthDate.getValue()), + () -> assertThat(user.getEmail().getValue()).isEqualTo("nahyeon@example.com") + ); + } + } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class ChangePassword { + + @Test + void 새_인코딩된_비밀번호로_변경된다() { + // arrange + User user = User.create( + new LoginId("nahyeon"), + "$2a$10$oldHash", + new UserName("홍길동"), + new BirthDate("1994-11-15"), + new Email("nahyeon@example.com") + ); + String newEncodedPassword = "$2a$10$newHash"; + + // act + user.changePassword(newEncodedPassword); + + // assert + assertThat(user.getPassword()).isEqualTo(newEncodedPassword); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/user/BCryptPasswordEncryptorTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/user/BCryptPasswordEncryptorTest.java new file mode 100644 index 00000000..88aba505 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/user/BCryptPasswordEncryptorTest.java @@ -0,0 +1,121 @@ +package com.loopers.infrastructure.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.UserErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +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; + +/** + * BCryptPasswordEncryptor 단위 테스트 + * + * BCryptPasswordEncoder가 null 입력 시 IllegalArgumentException을 발생시키므로, + * 어댑터 계층에서 CoreException으로 일관 처리하는지 검증한다. + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public class BCryptPasswordEncryptorTest { + + private BCryptPasswordEncryptor encryptor; + + @BeforeEach + void setUp() { + encryptor = new BCryptPasswordEncryptor(); + } + + @DisplayName("비밀번호 암호화 시,") + @Nested + class Encode { + + @Test + void 유효한_비밀번호면_암호화된_문자열을_반환한다() { + String encoded = encryptor.encode("Hx7!mK2@"); + + assertThat(encoded).isNotBlank(); + assertThat(encoded).startsWith("$2a$"); + } + + @Test + void 비밀번호가_null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + encryptor.encode(null); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 비밀번호가_빈_문자열이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + encryptor.encode(" "); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + } + + @DisplayName("비밀번호 일치 검증 시,") + @Nested + class Matches { + + @Test + void 일치하는_비밀번호면_true를_반환한다() { + String encoded = encryptor.encode("Hx7!mK2@"); + + assertThat(encryptor.matches("Hx7!mK2@", encoded)).isTrue(); + } + + @Test + void 불일치하는_비밀번호면_false를_반환한다() { + String encoded = encryptor.encode("Hx7!mK2@"); + + assertThat(encryptor.matches("wrongPw1!", encoded)).isFalse(); + } + + @Test + void 평문_비밀번호가_null이면_예외가_발생한다() { + String encoded = encryptor.encode("Hx7!mK2@"); + + CoreException exception = assertThrows(CoreException.class, () -> { + encryptor.matches(null, encoded); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 평문_비밀번호가_빈_문자열이면_예외가_발생한다() { + String encoded = encryptor.encode("Hx7!mK2@"); + + CoreException exception = assertThrows(CoreException.class, () -> { + encryptor.matches(" ", encoded); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 암호화된_비밀번호가_null이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + encryptor.matches("Hx7!mK2@", null); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + + @Test + void 암호화된_비밀번호가_빈_문자열이면_예외가_발생한다() { + CoreException exception = assertThrows(CoreException.class, () -> { + encryptor.matches("Hx7!mK2@", " "); + }); + + assertThat(exception.getErrorType()).isEqualTo(UserErrorType.INVALID_PASSWORD); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba6..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -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.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -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 ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java new file mode 100644 index 00000000..8658517a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java @@ -0,0 +1,215 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.user.UserRequest; +import com.loopers.interfaces.api.user.UserResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +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.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserApiE2ETest { + + private static final String USERS_URL = "/api/v1/users"; + private static final String ME_URL = "/api/v1/users/me"; + private static final String PASSWORD_URL = "/api/v1/users/password"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private UserRequest.SignupRequest validSignupRequest() { + return new UserRequest.SignupRequest("nahyeon", "Hx7!mK2@", "홍길동", "1994-11-15", "nahyeon@example.com"); + } + + private ResponseEntity signup(UserRequest.SignupRequest request) { + return testRestTemplate.postForEntity(USERS_URL, request, ApiResponse.class); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + // ========== 회원가입 ========== + + @DisplayName("POST /api/v1/users") + @Nested + class Signup { + + @Test + void 유효한_정보로_가입하면_201_Created_응답을_받는다() { + // act + ResponseEntity response = signup(validSignupRequest()); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + @Test + void 중복_로그인_ID로_가입하면_409_Conflict_응답을_받는다() { + // arrange + signup(validSignupRequest()); + + // act + ResponseEntity response = signup(validSignupRequest()); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void 잘못된_비밀번호_형식이면_400_Bad_Request_응답을_받는다() { + // arrange + UserRequest.SignupRequest request = new UserRequest.SignupRequest( + "nahyeon", "short", "홍길동", "1994-11-15", "nahyeon@example.com" + ); + + // act + ResponseEntity response = signup(request); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void 비밀번호에_생년월일이_포함되면_400_Bad_Request_응답을_받는다() { + // arrange + UserRequest.SignupRequest request = new UserRequest.SignupRequest( + "nahyeon", "A19941115!", "홍길동", "1994-11-15", "nahyeon@example.com" + ); + + // act + ResponseEntity response = signup(request); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + // ========== 내 정보 조회 ========== + + @DisplayName("GET /api/v1/users/me") + @Nested + class GetMyInfo { + + @Test + void 유효한_인증_정보로_조회하면_200_OK와_마스킹된_이름을_반환한다() { + // arrange + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); + + // act + ParameterizedTypeReference> type = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(headers), type); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("nahyeon") + ); + } + + @Test + void 잘못된_비밀번호로_조회하면_401_Unauthorized_응답을_받는다() { + // arrange + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "wrongPw1!"); + + // act + ResponseEntity response = + testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(headers), ApiResponse.class); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + // ========== 비밀번호 변경 ========== + + @DisplayName("PUT /api/v1/users/password") + @Nested + class ChangePassword { + + @Test + void 유효한_요청이면_200_OK_응답을_받는다() { + // arrange + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); + headers.setContentType(MediaType.APPLICATION_JSON); + UserRequest.ChangePasswordRequest body = new UserRequest.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); + + // act + ResponseEntity response = testRestTemplate.exchange( + PASSWORD_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void 변경_후_새_비밀번호로_인증되고_이전_비밀번호로는_실패한다() { + // arrange + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); + headers.setContentType(MediaType.APPLICATION_JSON); + UserRequest.ChangePasswordRequest body = new UserRequest.ChangePasswordRequest("Hx7!mK2@", "Nw8@pL3#"); + testRestTemplate.exchange(PASSWORD_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class); + + // act - 새 비밀번호로 조회 + HttpHeaders newHeaders = authHeaders("nahyeon", "Nw8@pL3#"); + ResponseEntity newPwResponse = + testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(newHeaders), ApiResponse.class); + + // act - 이전 비밀번호로 조회 + HttpHeaders oldHeaders = authHeaders("nahyeon", "Hx7!mK2@"); + ResponseEntity oldPwResponse = + testRestTemplate.exchange(ME_URL, HttpMethod.GET, new HttpEntity<>(oldHeaders), ApiResponse.class); + + // assert + assertAll( + () -> assertThat(newPwResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(oldPwResponse.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + + @Test + void 현재_비밀번호와_동일한_새_비밀번호면_400_Bad_Request_응답을_받는다() { + // arrange + signup(validSignupRequest()); + HttpHeaders headers = authHeaders("nahyeon", "Hx7!mK2@"); + headers.setContentType(MediaType.APPLICATION_JSON); + UserRequest.ChangePasswordRequest body = new UserRequest.ChangePasswordRequest("Hx7!mK2@", "Hx7!mK2@"); + + // act + ResponseEntity response = testRestTemplate.exchange( + PASSWORD_URL, HttpMethod.PUT, new HttpEntity<>(body, headers), ApiResponse.class); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java index 44db8c5e..8bbd1d20 100644 --- a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java @@ -1,32 +1,33 @@ package com.loopers.support.error; -import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class CoreExceptionTest { - @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지지 않으면 ErrorType의 메시지를 사용한다.") + @Test - void messageShouldBeErrorTypeMessage_whenCustomMessageIsNull() { + void 별도_메시지가_없으면_ErrorType의_메시지를_사용한다() { // arrange - ErrorType[] errorTypes = ErrorType.values(); + CommonErrorType[] errorTypes = CommonErrorType.values(); // act & assert - for (ErrorType errorType : errorTypes) { + for (CommonErrorType errorType : errorTypes) { CoreException exception = new CoreException(errorType); assertThat(exception.getMessage()).isEqualTo(errorType.getMessage()); } } - @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지면 해당 메시지를 사용한다.") @Test - void messageShouldBeCustomMessage_whenCustomMessageIsNotNull() { + void 별도_메시지가_주어지면_해당_메시지를_사용한다() { // arrange String customMessage = "custom message"; // act - CoreException exception = new CoreException(ErrorType.INTERNAL_ERROR, customMessage); + CoreException exception = new CoreException(CommonErrorType.INTERNAL_ERROR, customMessage); // assert assertThat(exception.getMessage()).isEqualTo(customMessage); diff --git a/http/commerce-api/user.http b/http/commerce-api/user.http new file mode 100644 index 00000000..b0286084 --- /dev/null +++ b/http/commerce-api/user.http @@ -0,0 +1,27 @@ +### 회원가입 +POST {{commerce-api}}/api/v1/users +Content-Type: application/json + +{ + "loginId": "{{loginId}}", + "password": "{{loginPw}}", + "name": "홍길동", + "birthDate": "1990-01-15", + "email": "test@example.com" +} + +### 내 정보 조회 +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: {{loginId}} +X-Loopers-LoginPw: {{loginPw}} + +### 비밀번호 변경 +PUT {{commerce-api}}/api/v1/users/password +Content-Type: application/json +X-Loopers-LoginId: {{loginId}} +X-Loopers-LoginPw: {{loginPw}} + +{ + "currentPassword": "{{loginPw}}", + "newPassword": "NewPass1892!@" +} diff --git a/http/http-client.env.json b/http/http-client.env.json index 0db34e6a..a3ae2295 100644 --- a/http/http-client.env.json +++ b/http/http-client.env.json @@ -2,4 +2,4 @@ "local": { "commerce-api": "http://localhost:8080" } -} +} \ No newline at end of file diff --git a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java index d15a9c76..f80ab1ab 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java +++ b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java @@ -7,7 +7,6 @@ import jakarta.persistence.MappedSuperclass; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; -import lombok.Getter; import java.time.ZonedDateTime; /** @@ -15,7 +14,6 @@ * 재사용성을 위해 이 외의 컬럼이나 동작은 추가하지 않는다. */ @MappedSuperclass -@Getter public abstract class BaseEntity { @Id @@ -70,4 +68,20 @@ public void restore() { this.deletedAt = null; } } + + public Long getId() { + return this.id; + } + + public ZonedDateTime getCreatedAt() { + return this.createdAt; + } + + public ZonedDateTime getUpdatedAt() { + return this.updatedAt; + } + + public ZonedDateTime getDeletedAt() { + return this.deletedAt; + } } diff --git a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java index 35bf94f0..e637f61e 100644 --- a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java +++ b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java @@ -4,9 +4,13 @@ import org.springframework.context.annotation.Configuration; import org.testcontainers.utility.DockerImageName; +import java.time.Duration; + @Configuration public class RedisTestContainersConfig { - private static final RedisContainer redisContainer = new RedisContainer(DockerImageName.parse("redis:latest")); + private static final RedisContainer redisContainer = new RedisContainer( + DockerImageName.parse("redis:7-alpine").asCompatibleSubstituteFor("redis")) + .withStartupTimeout(Duration.ofMinutes(2)); static { redisContainer.start();