diff --git a/.claude/commands/create-pr.md b/.claude/commands/create-pr.md new file mode 100644 index 00000000..00b1102b --- /dev/null +++ b/.claude/commands/create-pr.md @@ -0,0 +1,49 @@ +현재 브랜치의 변경사항을 분석하여 `.github/pull_request_template.md` 양식에 맞는 PR을 자동 생성한다. + +## 수행 절차 + +### 1단계: 변경사항 분석 +아래 명령어를 **병렬로** 실행하여 정보를 수집한다: +- `git status` (변경된 파일 목록) +- `git log main..HEAD --oneline` (현재 브랜치의 커밋 내역) +- `git diff main...HEAD --stat` (변경된 파일 통계) +- `git diff main...HEAD` (전체 변경 내용) + +### 2단계: PR 본문 작성 +`.github/pull_request_template.md` 양식을 읽고, 수집한 정보를 기반으로 아래 규칙에 따라 본문을 작성한다. + +#### 📌 Summary +- **배경**: 이 변경이 필요한 이유 (기존 문제, 요구사항) +- **목표**: 이번 PR에서 달성하려는 것 +- **결과**: 변경 후 달라지는 점 + +#### 🧭 Context & Decision +- **문제 정의**: 현재 동작/제약, 문제(리스크), 성공 기준을 구체적으로 기술 +- **선택지와 결정**: 코드에서 실제 사용된 기술적 선택(패턴, 라이브러리, 구조)과 그 이유를 기술. 대안이 명확하지 않으면 "단일 접근" 으로 표기 + +#### 🏗️ Design Overview +- **변경 범위**: 실제 변경된 모듈/도메인, 신규 추가 파일, 제거/대체된 파일을 나열 +- **주요 컴포넌트 책임**: 변경된 주요 클래스/파일의 역할을 `ComponentName`: 설명 형태로 기술 + +#### 🔁 Flow Diagram +- **핵심 API 흐름마다** Mermaid `sequenceDiagram`을 작성한다 +- 참여자(participant)는 실제 클래스명을 사용한다 +- `autonumber`를 포함한다 +- 정상 흐름과 예외 흐름(alt/else)을 모두 포함한다 +- API가 여러 개면 각각 별도 다이어그램으로 작성한다 + +### 3단계: PR 생성 +- 브랜치가 리모트에 push되지 않았으면 `git push -u origin ` 실행 +- `gh pr create` 명령어로 PR 생성 +- PR 제목은 70자 이내, 변경의 핵심을 요약 +- PR 본문은 HEREDOC으로 전달 + +```bash +gh pr create --title "PR 제목" --body "$(cat <<'EOF' +... 작성된 PR 본문 ... +EOF +)" +``` + +### 4단계: 결과 보고 +- 생성된 PR URL을 사용자에게 반환한다 diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e..00000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## 🧪 Implementation Quest - -> 지정된 **단위 테스트 / 통합 테스트 / E2E 테스트 케이스**를 필수로 구현하고, 모든 테스트를 통과시키는 것을 목표로 합니다. - -### 회원 가입 - -**🧱 단위 테스트** - -- [ ] ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다. - -**🔗 통합 테스트** - -- [ ] 회원 가입시 User 저장이 수행된다. ( spy 검증 ) -- [ ] 이미 가입된 ID 로 회원가입 시도 시, 실패한다. - -**🌐 E2E 테스트** - -- [ ] 회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다. -- [ ] 회원 가입 시에 성별이 없을 경우, `400 Bad Request` 응답을 반환한다. - -### 내 정보 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다. -- [ ] 존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다. - -### 포인트 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다. -- [ ] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..a88a85fa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +# CLAUDE.md + +이 파일은 Claude Code가 프로젝트를 이해하는 데 필요한 컨텍스트를 제공합니다. + +## 프로젝트 개요 + +**프로젝트명**: loopers-java-spring-template +**그룹 ID**: com.loopers +**라이선스**: LICENSE 파일 참조 + +커머스 도메인을 위한 Java/Spring Boot 기반 멀티 모듈 백엔드 템플릿 프로젝트입니다. + +## 기술 스택 및 버전 + +| 기술 | 버전 | +|------|------| +| Java | 21 | +| Spring Boot | 3.4.4 | +| Spring Cloud Dependencies | 2024.0.1 | +| Spring Dependency Management | 1.1.7 | +| Lombok | Spring Boot BOM | +| QueryDSL | Spring Boot BOM (Jakarta) | +| SpringDoc OpenAPI | 2.7.0 | +| Micrometer | Spring Boot BOM | +| Testcontainers | Spring Boot BOM | +| JUnit 5 | Spring Boot BOM | +| Mockito | 5.14.0 | +| SpringMockK | 4.0.2 | +| Instancio JUnit | 5.0.2 | +| Slack Appender | 1.6.1 | + +## 모듈 구조 + +``` +loopers-java-spring-template/ +├── apps/ # 실행 가능한 애플리케이션 (BootJar) +│ ├── commerce-api/ # REST API 서버 (Web, OpenAPI) +│ ├── commerce-streamer/ # Kafka 스트림 처리 서버 +│ └── commerce-batch/ # Spring Batch 애플리케이션 +│ +├── modules/ # 공유 라이브러리 모듈 +│ ├── jpa/ # JPA + QueryDSL + MySQL +│ ├── redis/ # Spring Data Redis +│ └── kafka/ # Spring Kafka +│ +├── supports/ # 횡단 관심사 지원 모듈 +│ ├── jackson/ # Jackson 직렬화 설정 +│ ├── logging/ # 로깅 + Slack Appender +│ └── monitoring/ # Prometheus + Micrometer +│ +├── docker/ # Docker 관련 설정 +└── http/ # HTTP 요청 파일 (IntelliJ HTTP Client) +``` + +### 모듈 의존성 관계 + +- **commerce-api**: jpa, redis, jackson, logging, monitoring +- **commerce-streamer**: jpa, redis, kafka, jackson, logging, monitoring +- **commerce-batch**: jpa, redis, jackson, logging, monitoring + +## 빌드 및 실행 + +```bash +# 전체 빌드 +./gradlew build + +# 특정 앱 실행 +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-streamer:bootRun +./gradlew :apps:commerce-batch:bootRun + +# 테스트 실행 +./gradlew test +``` + +## 테스트 환경 + +- 테스트 시 `Asia/Seoul` 타임존 사용 +- 테스트 프로파일: `test` +- Testcontainers 사용 (MySQL, Redis, Kafka) +- JaCoCo 코드 커버리지 리포트 생성 (XML 포맷) + +## 주요 설정 + +- **버전 관리**: Git 커밋 해시를 기본 버전으로 사용 +- **빌드 타입**: + - `apps/*` 모듈: BootJar (실행 가능한 JAR) + - `modules/*`, `supports/*` 모듈: 일반 JAR (라이브러리) + +## 코드 스타일 + +- Lombok 사용 +- Jackson JSR310 모듈로 Java Time API 직렬화 +- QueryDSL Jakarta 스펙 사용 + + +## 개발 규칙 +### 진행 Workflow - 증강 코딩 +- **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. +- **중간 결과 보고** : AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입. +- **설계 주도권 유지** : AI 가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행. + +### 개발 Workflow - TDD (Red > Green > Refactor) +- 모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert) +#### 1. Red Phase : 실패하는 테스트 먼저 작성 +- 요구사항을 만족하는 기능 테스트 케이스 작성 +- 테스트 예시 +#### 2. Green Phase : 테스트를 통과하는 코드 작성 +- Red Phase 의 테스트가 모두 통과할 수 있는 코드 작성 +- 오버엔지니어링 금지 +#### 3. Refactor Phase : 불필요한 코드 제거 및 품질 개선 +- 불필요한 private 함수 지양, 객체지향적 코드 작성 +- unused import 제거 +- 성능 최적화 +- 모든 테스트 케이스가 통과해야 함 +## 주의사항 +### 1. Never Do +- 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이요한 구현을 하지 말 것 +- null-safety 하지 않게 코드 작성하지 말 것 (Java 의 경우, Optional 을 활용할 것) +- println 코드 남기지 말 것 + +### 2. Recommendation +- 실제 API 를 호출해 확인하는 E2E 테스트 코드 작성 +- 재사용 가능한 객체 설계 +- 성능 최적화에 대한 대안 및 제안 +- 개발 완료된 API 의 경우, `.http/**.http` 에 분류해 작성 + +### 3. Priority +1. 실제 동작하는 해결책만 고려 +2. null-safety, thread-safety 고려 +3. 테스트 가능한 구조로 설계 +4. 기존 코드 패턴 분석 후 일관성 유지 \ No newline at end of file diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..6acd8606 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -11,6 +11,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + // security + implementation("org.springframework.security:spring-security-crypto") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 00000000..56fdc56d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,27 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserFacade { + + private final UserService userService; + + public UserInfo register(String loginId, String password, String name, String birthDate, String email) { + User user = userService.register(loginId, password, name, birthDate, email); + return UserInfo.from(user); + } + + public UserInfo getMyInfo(String loginId, String password) { + User user = userService.authenticate(loginId, password); + return UserInfo.from(user); + } + + public void changePassword(String loginId, String currentPassword, String newPassword) { + userService.changePassword(loginId, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 00000000..ab17729e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +public record UserInfo( + Long id, + String loginId, + String name, + String maskedName, + String birthDate, + String email +) { + public static UserInfo from(User user) { + return new UserInfo( + user.getId(), + user.getLoginId(), + user.getName(), + user.getMaskedName(), + user.getBirthDate(), + user.getEmail() + ); + } +} 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..9fa63b86 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,21 @@ +package com.loopers.config; + +import com.loopers.interfaces.api.auth.AuthenticatedUserArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthenticatedUserArgumentResolver authenticatedUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authenticatedUserArgumentResolver); + } +} 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..5ea7523e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,177 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.util.regex.Pattern; + +@Entity +@Table(name = "users") +public class User extends BaseEntity { + + private static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); + private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + private static final Pattern NAME_PATTERN = Pattern.compile("^[가-힣a-zA-Z]+$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[a-zA-Z0-9!@#$%^&*]+$"); + private static final DateTimeFormatter BIRTH_DATE_FORMATTER = DateTimeFormatter.ofPattern("uuuuMMdd") + .withResolverStyle(ResolverStyle.STRICT); + + private static final int MAX_LOGIN_ID_BYTES = 30; + private static final int MAX_NAME_BYTES = 30; + private static final int MIN_PASSWORD_LENGTH = 8; + private static final int MAX_PASSWORD_LENGTH = 16; + private static final int BIRTH_DATE_SUBSTRING_LENGTH = 4; + + @Column(name = "login_id", nullable = false, unique = true) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "birth_date", nullable = false) + private String birthDate; + + @Column(name = "email", nullable = false) + private String email; + + protected User() {} + + public User(String loginId, String password, String name, String birthDate, String email) { + validateLoginId(loginId); + validateName(name); + validateBirthDate(birthDate); + validateEmail(email); + validatePassword(password, birthDate); + + this.loginId = loginId; + this.password = PASSWORD_ENCODER.encode(password); + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public String getLoginId() { + return loginId; + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } + + public boolean matchPassword(String rawPassword) { + return PASSWORD_ENCODER.matches(rawPassword, this.password); + } + + public void changePassword(String newPassword) { + if (matchPassword(newPassword)) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 다르게 설정해야 합니다."); + } + validatePassword(newPassword, this.birthDate); + this.password = PASSWORD_ENCODER.encode(newPassword); + } + + public String getMaskedName() { + if (name.length() <= 1) { + return name; + } + char first = name.charAt(0); + char last = name.charAt(name.length() - 1); + String middle = "*".repeat(name.length() - 2); + return first + middle + last; + } + + private void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); + } + if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 사용할 수 있습니다."); + } + if (loginId.getBytes(StandardCharsets.UTF_8).length > MAX_LOGIN_ID_BYTES) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 30바이트를 초과할 수 없습니다."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + } + if (!NAME_PATTERN.matcher(name).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 한글과 영문만 사용할 수 있습니다."); + } + if (name.getBytes(StandardCharsets.UTF_8).length > MAX_NAME_BYTES) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 30바이트를 초과할 수 없습니다."); + } + } + + private void validateBirthDate(String birthDate) { + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); + } + if (birthDate.length() != 8 || !birthDate.matches("\\d{8}")) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 YYYYMMDD 형식이어야 합니다."); + } + try { + LocalDate.parse(birthDate, BIRTH_DATE_FORMATTER); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 날짜입니다."); + } + } + + private void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다."); + } + if (!EMAIL_PATTERN.matcher(email).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 이메일 형식입니다."); + } + } + + private void validatePassword(String password, String birthDate) { + if (password == null || password.length() < MIN_PASSWORD_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8자 이상이어야 합니다."); + } + if (password.length() > MAX_PASSWORD_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 16자 이하이어야 합니다."); + } + if (!PASSWORD_PATTERN.matcher(password).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자(!@#$%^&*)만 사용할 수 있습니다."); + } + if (containsBirthDateSubstring(password, birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일 정보를 포함할 수 없습니다."); + } + } + + private boolean containsBirthDateSubstring(String password, String birthDate) { + for (int i = 0; i <= birthDate.length() - BIRTH_DATE_SUBSTRING_LENGTH; i++) { + String substring = birthDate.substring(i, i + BIRTH_DATE_SUBSTRING_LENGTH); + if (password.contains(substring)) { + return true; + } + } + return false; + } +} 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..15889936 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + 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..5fb27a41 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,46 @@ +package com.loopers.domain.user; + +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 UserService { + + private final UserRepository userRepository; + + @Transactional + public User register(String loginId, String password, String name, String birthDate, String email) { + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인 ID입니다."); + } + User user = new User(loginId, password, name, birthDate, email); + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public User getUserByLoginId(String loginId) { + return userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND, "[loginId = " + loginId + "] 사용자를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public User authenticate(String loginId, String password) { + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND, "존재하지 않는 사용자입니다.")); + + if (!user.matchPassword(password)) { + throw new CoreException(ErrorType.PASSWORD_MISMATCH, "비밀번호가 일치하지 않습니다."); + } + return user; + } + + @Transactional + public void changePassword(String loginId, String currentPassword, String newPassword) { + User user = authenticate(loginId, currentPassword); + user.changePassword(newPassword); + } +} 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..fb0e51c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 00000000..9a9ed24a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return userJpaRepository.existsByLoginId(loginId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java new file mode 100644 index 00000000..6933472f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java @@ -0,0 +1,4 @@ +package com.loopers.interfaces.api.auth; + +public record AuthenticatedUser(String loginId, String password) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java new file mode 100644 index 00000000..a4a25634 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUserArgumentResolver.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class AuthenticatedUserArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(AuthenticatedUser.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + String loginId = webRequest.getHeader(HEADER_LOGIN_ID); + String password = webRequest.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "X-Loopers-LoginId 헤더가 필요합니다."); + } + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "X-Loopers-LoginPw 헤더가 필요합니다."); + } + + return new AuthenticatedUser(loginId, password); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java new file mode 100644 index 00000000..944fb294 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,34 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User V1 API", description = "사용자 관련 API입니다.") +public interface UserV1ApiSpec { + + @Operation( + summary = "회원가입", + description = "새로운 사용자를 등록합니다." + ) + ApiResponse register(UserV1Dto.RegisterRequest request); + + @Operation( + summary = "내 정보 조회", + description = "현재 로그인한 사용자의 정보를 조회합니다." + ) + ApiResponse getMe( + @Parameter(hidden = true) AuthenticatedUser authenticatedUser + ); + + @Operation( + summary = "비밀번호 변경", + description = "현재 로그인한 사용자의 비밀번호를 변경합니다." + ) + ApiResponse changePassword( + @Parameter(hidden = true) AuthenticatedUser authenticatedUser, + UserV1Dto.ChangePasswordRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 00000000..05660b0a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private final UserFacade userFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse register(@RequestBody UserV1Dto.RegisterRequest request) { + UserInfo info = userFacade.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + return ApiResponse.success(UserV1Dto.RegisterResponse.from(info)); + } + + @GetMapping("/me") + @Override + public ApiResponse getMe(AuthenticatedUser authenticatedUser) { + UserInfo info = userFacade.getMyInfo( + authenticatedUser.loginId(), + authenticatedUser.password() + ); + return ApiResponse.success(UserV1Dto.MeResponse.from(info)); + } + + @PatchMapping("/me/password") + @Override + public ApiResponse changePassword( + AuthenticatedUser authenticatedUser, + @RequestBody UserV1Dto.ChangePasswordRequest request + ) { + userFacade.changePassword( + authenticatedUser.loginId(), + request.currentPassword(), + request.newPassword() + ); + return ApiResponse.success(UserV1Dto.ChangePasswordResponse.success()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 00000000..67bc1be1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,47 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; + +public class UserV1Dto { + + public record RegisterRequest( + String loginId, + String password, + String name, + String birthDate, + String email + ) {} + + public record RegisterResponse(Long userId) { + public static RegisterResponse from(UserInfo info) { + return new RegisterResponse(info.id()); + } + } + + public record MeResponse( + String loginId, + String name, + String birthDate, + String email + ) { + public static MeResponse from(UserInfo info) { + return new MeResponse( + info.loginId(), + info.maskedName(), + info.birthDate(), + info.email() + ); + } + } + + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} + + public record ChangePasswordResponse(String message) { + public static ChangePasswordResponse success() { + return new ChangePasswordResponse("비밀번호가 성공적으로 변경되었습니다."); + } + } +} 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..d64c6b49 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 @@ -11,7 +11,12 @@ 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(), "이미 존재하는 리소스입니다."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + + /** 인증 관련 에러 */ + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."), + USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "존재하지 않는 사용자입니다."), + PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "PASSWORD_MISMATCH", "비밀번호가 일치하지 않습니다."); private final HttpStatus status; private final String code; 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..c87e0e65 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,209 @@ +package com.loopers.domain.user; + +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입할 때,") + @Nested + class Register { + + @DisplayName("유효한 정보로 가입하면, 사용자가 생성된다.") + @Test + void createsUser_whenValidInfoIsProvided() { + // arrange + String loginId = "testuser"; + String password = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + User result = userService.register(loginId, password, name, birthDate, email); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(result.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("이미 존재하는 로그인 ID로 가입하면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflictException_whenLoginIdAlreadyExists() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.register(loginId, "Another12!", "김철수", "19950505", "another@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("사용자를 조회할 때,") + @Nested + class GetUserByLoginId { + + @DisplayName("존재하는 로그인 ID로 조회하면, 사용자 정보를 반환한다.") + @Test + void returnsUser_whenLoginIdExists() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + User result = userService.getUserByLoginId(loginId); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId) + ); + } + + @DisplayName("존재하지 않는 로그인 ID로 조회하면, USER_NOT_FOUND 예외가 발생한다.") + @Test + void throwsUserNotFoundException_whenLoginIdDoesNotExist() { + // arrange + String loginId = "nonexistent"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.getUserByLoginId(loginId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.USER_NOT_FOUND); + } + } + + @DisplayName("인증할 때,") + @Nested + class Authenticate { + + @DisplayName("올바른 로그인 ID와 비밀번호로 인증하면, 사용자 정보를 반환한다.") + @Test + void returnsUser_whenCredentialsAreValid() { + // arrange + String loginId = "testuser"; + String password = "Test1234!"; + userService.register(loginId, password, "홍길동", "19900101", "test@example.com"); + + // act + User result = userService.authenticate(loginId, password); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId) + ); + } + + @DisplayName("존재하지 않는 로그인 ID로 인증하면, USER_NOT_FOUND 예외가 발생한다.") + @Test + void throwsUserNotFoundException_whenLoginIdDoesNotExist() { + // arrange + String loginId = "nonexistent"; + String password = "Test1234!"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.authenticate(loginId, password); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.USER_NOT_FOUND); + } + + @DisplayName("잘못된 비밀번호로 인증하면, PASSWORD_MISMATCH 예외가 발생한다.") + @Test + void throwsPasswordMismatchException_whenPasswordIsWrong() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.authenticate(loginId, "WrongPass1!"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + } + } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class ChangePassword { + + @DisplayName("올바른 현재 비밀번호와 유효한 새 비밀번호로 변경하면, 성공한다.") + @Test + void succeeds_whenCurrentPasswordIsCorrectAndNewPasswordIsValid() { + // arrange + String loginId = "testuser"; + String currentPassword = "Test1234!"; + String newPassword = "NewPass12!"; + userService.register(loginId, currentPassword, "홍길동", "19900101", "test@example.com"); + + // act + userService.changePassword(loginId, currentPassword, newPassword); + + // assert + User updatedUser = userService.authenticate(loginId, newPassword); + assertThat(updatedUser).isNotNull(); + } + + @DisplayName("잘못된 현재 비밀번호로 변경하면, PASSWORD_MISMATCH 예외가 발생한다.") + @Test + void throwsPasswordMismatchException_whenCurrentPasswordIsWrong() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.changePassword(loginId, "WrongPass1!", "NewPass12!"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.PASSWORD_MISMATCH); + } + } +} 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..42748763 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,364 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +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 UserTest { + + @DisplayName("User를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 입력이 주어지면, 정상적으로 생성된다.") + @Test + void createsUser_whenValidInputIsProvided() { + // arrange + String loginId = "testuser123"; + String password = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + User user = new User(loginId, password, name, birthDate, email); + + // assert + assertAll( + () -> assertThat(user.getLoginId()).isEqualTo(loginId), + () -> assertThat(user.getName()).isEqualTo(name), + () -> assertThat(user.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(user.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("loginId가 null이거나 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenLoginIdIsNullOrEmpty(String loginId) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId가 영문+숫자 외 문자를 포함하면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"test@user", "test user", "테스트유저", "test_user"}) + void throwsBadRequestException_whenLoginIdContainsInvalidChars(String loginId) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId가 30바이트를 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenLoginIdExceeds30Bytes() { + // arrange + String loginId = "a".repeat(31); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 null이거나 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenNameIsNullOrEmpty(String name) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", name, "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 한글+영문 외 문자를 포함하면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"홍길동123", "홍길동!", "홍 길동"}) + void throwsBadRequestException_whenNameContainsInvalidChars(String name) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", name, "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 30바이트를 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameExceeds30Bytes() { + // arrange - 한글 1자 = 3바이트, 11자 = 33바이트 + String name = "가".repeat(11); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", name, "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDate가 null이거나 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenBirthDateIsNullOrEmpty(String birthDate) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "홍길동", birthDate, "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDate가 YYYYMMDD 포맷이 아니면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"1990-01-01", "19901", "990101", "2000/01/01"}) + void throwsBadRequestException_whenBirthDateHasInvalidFormat(String birthDate) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "홍길동", birthDate, "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDate가 유효하지 않은 날짜면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"19901301", "19900132", "20000230", "19000001"}) + void throwsBadRequestException_whenBirthDateIsInvalidDate(String birthDate) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "홍길동", birthDate, "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("email이 null이거나 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @NullAndEmptySource + void throwsBadRequestException_whenEmailIsNullOrEmpty(String email) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "홍길동", "19900101", email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("email이 유효하지 않은 형식이면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"test", "test@", "@example.com", "test@.com", "test@com"}) + void throwsBadRequestException_whenEmailHasInvalidFormat(String email) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234!", "홍길동", "19900101", email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password가 8자 미만이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenPasswordIsTooShort() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test12!", "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password가 16자를 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenPasswordIsTooLong() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", "Test1234567890123!", "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password에 허용되지 않은 문자가 포함되면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"Test1234~", "Test1234()", "Test1234<>"}) + void throwsBadRequestException_whenPasswordContainsInvalidChars(String password) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", password, "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password에 생년월일 4자리 이상 부분문자열이 포함되면, BAD_REQUEST 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"Test1990!", "Test0101!", "Test9001!"}) + void throwsBadRequestException_whenPasswordContainsBirthDateSubstring(String password) { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new User("testuser", password, "홍길동", "19900101", "test@example.com"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("비밀번호를 검증할 때,") + @Nested + class MatchPassword { + + @DisplayName("비밀번호가 일치하면, true를 반환한다.") + @Test + void returnsTrue_whenPasswordMatches() { + // arrange + User user = new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + boolean result = user.matchPassword("Test1234!"); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("비밀번호가 일치하지 않으면, false를 반환한다.") + @Test + void returnsFalse_whenPasswordDoesNotMatch() { + // arrange + User user = new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + boolean result = user.matchPassword("WrongPass1!"); + + // assert + assertThat(result).isFalse(); + } + } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class ChangePassword { + + @DisplayName("유효한 새 비밀번호로 변경하면, 성공한다.") + @Test + void succeeds_whenNewPasswordIsValid() { + // arrange + User user = new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + user.changePassword("NewPass12!"); + + // assert + assertThat(user.matchPassword("NewPass12!")).isTrue(); + } + + @DisplayName("현재 비밀번호와 동일하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNewPasswordIsSameAsCurrent() { + // arrange + User user = new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + user.changePassword("Test1234!"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 규칙을 위반하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNewPasswordViolatesRules() { + // arrange + User user = new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + user.changePassword("short"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이름을 마스킹할 때,") + @Nested + class GetMaskedName { + + @DisplayName("2글자 이상이면, 첫 글자와 마지막 글자만 보이고 중간은 *로 마스킹된다.") + @Test + void masksMiddleCharacters_whenNameHasTwoOrMoreCharacters() { + // arrange + User user = new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + String result = user.getMaskedName(); + + // assert + assertThat(result).isEqualTo("홍*동"); + } + + @DisplayName("1글자이면, 그대로 반환된다.") + @Test + void returnsAsIs_whenNameHasOneCharacter() { + // arrange + User user = new User("testuser", "Test1234!", "김", "19900101", "test@example.com"); + + // act + String result = user.getMaskedName(); + + // assert + assertThat(result).isEqualTo("김"); + } + + @DisplayName("영문 이름도 마스킹된다.") + @Test + void masksEnglishName() { + // arrange + User user = new User("testuser", "Test1234!", "John", "19900101", "test@example.com"); + + // act + String result = user.getMaskedName(); + + // assert + assertThat(result).isEqualTo("J**n"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java new file mode 100644 index 00000000..47d86b79 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,289 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.user.User; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + + private static final String ENDPOINT_REGISTER = "/api/v1/users"; + private static final String ENDPOINT_ME = "/api/v1/users/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/users/me/password"; + + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserJpaRepository userJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/users") + @Nested + class Register { + + @DisplayName("유효한 정보로 회원가입하면, 201 CREATED와 userId를 반환한다.") + @Test + void returns201AndUserId_whenValidInfoIsProvided() { + // arrange + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Test1234!", + "홍길동", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isNotNull() + ); + } + + @DisplayName("이미 존재하는 loginId로 회원가입하면, 409 CONFLICT를 반환한다.") + @Test + void returns409Conflict_whenLoginIdAlreadyExists() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Another12!", + "김철수", + "19950505", + "another@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("유효하지 않은 입력으로 회원가입하면, 400 BAD_REQUEST를 반환한다.") + @Test + void returns400BadRequest_whenInputIsInvalid() { + // arrange + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "test@user", // 잘못된 loginId + "Test1234!", + "홍길동", + "19900101", + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/users/me") + @Nested + class GetMe { + + @DisplayName("유효한 인증 정보로 조회하면, 200 OK와 마스킹된 사용자 정보를 반환한다.") + @Test + void returns200AndMaskedUserInfo_whenCredentialsAreValid() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍*동"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo("19900101"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("인증 헤더가 누락되면, 400 BAD_REQUEST를 반환한다.") + @Test + void returns400BadRequest_whenAuthHeaderIsMissing() { + // arrange + HttpHeaders headers = new HttpHeaders(); + // 헤더 없음 + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 사용자로 조회하면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returns401Unauthorized_whenUserNotFound() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nonexistent"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("비밀번호가 틀리면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returns401Unauthorized_whenPasswordIsWrong() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("PATCH /api/v1/users/me/password") + @Nested + class ChangePassword { + + @DisplayName("유효한 인증과 비밀번호로 변경하면, 200 OK와 성공 메시지를 반환한다.") + @Test + void returns200AndSuccessMessage_whenCredentialsAndPasswordAreValid() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!", "NewPass12!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().message()).isEqualTo("비밀번호가 성공적으로 변경되었습니다.") + ); + } + + @DisplayName("헤더 비밀번호와 body currentPassword가 다르면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returns401Unauthorized_whenCurrentPasswordIsWrong() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("WrongPass1!", "NewPass12!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("새 비밀번호가 규칙을 위반하면, 400 BAD_REQUEST를 반환한다.") + @Test + void returns400BadRequest_whenNewPasswordIsInvalid() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!", "short"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, 400 BAD_REQUEST를 반환한다.") + @Test + void returns400BadRequest_whenNewPasswordIsSameAsCurrent() { + // arrange + userJpaRepository.save(new User("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest("Test1234!", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/http/commerce-api/user-v1.http b/http/commerce-api/user-v1.http new file mode 100644 index 00000000..a392a881 --- /dev/null +++ b/http/commerce-api/user-v1.http @@ -0,0 +1,27 @@ +### 회원가입 +POST {{commerce-api}}/api/v1/users +Content-Type: application/json + +{ + "loginId": "testuser", + "password": "Test1234!", + "name": "홍길동", + "birthDate": "19900101", + "email": "test@example.com" +} + +### 내 정보 조회 +GET {{commerce-api}}/api/v1/users/me +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 비밀번호 변경 +PATCH {{commerce-api}}/api/v1/users/me/password +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass12!" +} diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java index 9c41edac..0495cb5b 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java @@ -14,6 +14,7 @@ public class MySqlTestContainersConfig { .withDatabaseName("loopers") .withUsername("test") .withPassword("test") + .withEnv("MYSQL_ROOT_PASSWORD", "test") .withExposedPorts(3306) .withCommand( "--character-set-server=utf8mb4",