diff --git a/.github/workflows/cd-deploy.yml b/.github/workflows/cd-deploy.yml
index 60a9f59d..d0cf055f 100644
--- a/.github/workflows/cd-deploy.yml
+++ b/.github/workflows/cd-deploy.yml
@@ -47,18 +47,18 @@ jobs:
- name: Configure Docker for GCR
run: gcloud auth configure-docker
-
+
- name: Build Docker Image
run: |
docker build -t $IMAGE_NAME:$GITHUB_SHA \
-t $IMAGE_NAME:latest \
.
-
+
- name: Push Docker Image to GCR
run: |
docker push $IMAGE_NAME:$GITHUB_SHA
docker push $IMAGE_NAME:latest
-
+
- name: Deploy to Cloud Run
run: |
gcloud run deploy $SERVICE_NAME \
@@ -73,12 +73,12 @@ jobs:
--max-instances 10 \
--min-instances 0 \
--timeout=900 \
- --startup-cpu-boost \
+ --cpu-boost \
--execution-environment gen2 \
--port=8080 \
--set-env-vars="SPRING_PROFILES_ACTIVE=prod,DB_NAME=${{ secrets.DB_NAME }},DB_USERNAME=cmall,SPRING_APPLICATION_NAME=feedshop,PORT=8080,SERVER_SSL_ENABLED=false,SERVER_ADDRESS=0.0.0.0" \
--update-secrets="DB_PASSWORD=shopchat-db-password:latest,MAILGUN_API_KEY=mailgun_api_key:latest,MAILGUN_DOMAIN=mailgun_domain:latest,MAILGUN_EMAIL=mailgun_email:latest,GCS_ID=gcs_id:latest,GCS_BUCKET=gcs_prod_bucket:latest,JWT_SECRET=feedshop-jwt-secret-key:latest,RECAPTCHA_SECRET_KEY=recaptcha_secret_key:latest,GOOGLE_CLIENT_ID=google_client_id:latest,GOOGLE_CLIENT_SECRET=google_client_secret:latest,KAKAO_CLIENT_ID=kakao_client_id:latest,KAKAO_CLIENT_SECRET=kakao_client_secret:latest,OPENAI_API_KEY=openAI_api_key:latest"
-
+
- name: Wait for deployment
run: |
echo "Waiting for deployment to complete..."
@@ -86,7 +86,7 @@ jobs:
# 서비스 상태 확인
gcloud run services describe $SERVICE_NAME --region=$REGION --format="value(status.conditions[0].status,status.conditions[0].message)"
-
+
- name: Check deployment status and logs
if: failure()
run: |
@@ -100,13 +100,13 @@ jobs:
echo "=== Service Status ==="
gcloud run services describe $SERVICE_NAME --region=$REGION --format="yaml(status)"
-
+
- name: Get Service URL
run: |
SERVICE_URL=$(gcloud run services describe $SERVICE_NAME --region=$REGION --format="value(status.url)")
echo "Service deployed at: $SERVICE_URL"
echo "SERVICE_URL=$SERVICE_URL" >> $GITHUB_ENV
-
+
- name: Health Check
run: |
echo "Waiting for service to be ready..."
@@ -147,4 +147,4 @@ jobs:
else
echo "❌ Deployment failed!"
exit 1
- fi
+ fi
\ No newline at end of file
diff --git a/README.md b/README.md
index 1c81cad1..8ff93654 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@
- [📖 API 문서](#-api-문서)
- [🧪 테스트](#-테스트)
- [🔧 개발 환경](#-개발-환경)
-- [📈 CI/CD](#cicd)
+- [📈 CI/CD](#-cicd)
- [🤝 기여 방법](#-기여-방법)
- [📝 라이선스](#-라이선스)
@@ -35,7 +35,6 @@
- **상품 관리**: 상품 등록, 수정, 삭제, 옵션 관리, 이미지 업로드
- **장바구니**: 상품 추가/삭제, 수량 변경, 선택 상품 관리
- **주문 시스템**: 주문 생성, 주문 내역 조회, 재고 관리, 포인트 사용
-- **결제 연동**: 다양한 결제 수단 지원 (구현 예정)
### 👤 사용자 관리
@@ -67,97 +66,16 @@
### 🤖 AI 기능
- **상품 추천**: OpenAI 기반 개인화 상품 추천
-- **AI 챗봇**: 상품 문의 및 고객 지원 (구현 예정)
-- **스마트 검색**: AI 기반 상품 검색 및 필터링 (구현 예정)
+- **스마트 검색**: AI 기반 상품 검색 및 필터링
---
+
## 🏗️ 아키텍처
### 전체 시스템 아키텍처
+
-```mermaid
-graph TB
- %% Frontend Layer
- subgraph "Frontend (Vercel)"
- FE["React Frontend
🌐 www.feedshop.store"]
- end
-
- %% CDN & Storage
- subgraph "Static Assets"
- CDN["CDN
📁 cdn-feedshop.store
(Google Cloud Storage)"]
- end
-
- %% Backend Services
- subgraph "GCP Backend Services"
- subgraph "Development Environment"
- DEV_APP["Development API
🔧 Spring Boot
(Local/Dev Server)"]
- DEV_DB[(Development DB
🗄️ MySQL
Compute Engine + Docker)]
- end
-
- subgraph "Production Environment"
- PROD_APP["Production API
🚀 Spring Boot
Cloud Run
feedshop-springboot-561086069695.asia-northeast3.run.app"]
- PROD_DB[(Production DB
☁️ Cloud SQL MySQL
feedshop-db)]
- end
- end
-
- %% External Services
- subgraph "External Services"
- MAILGUN["Mailgun
📧 Email Service"]
- RECAPTCHA["Google reCAPTCHA
🛡️ Bot Protection"]
- SONAR["SonarCloud
📊 Code Quality"]
- OPENAI["OpenAI
🤖 AI Services"]
- OAUTH["OAuth2 Providers
🔐 Google, Kakao"]
- end
-
- %% CI/CD Pipeline
- subgraph "CI/CD Pipeline"
- GITHUB["GitHub Repository
📚 Source Code"]
- GH_ACTIONS["GitHub Actions
⚙️ CI/CD Pipeline"]
- end
-
- %% User Interactions
- USER["👤 Users"]
- DEV["👨💻 Developers"]
-
- %% Frontend Connections
- USER --> FE
- FE --> PROD_APP
- FE --> CDN
-
- %% Development Flow
- DEV --> GITHUB
- DEV_APP --> DEV_DB
-
- %% Production Flow
- PROD_APP --> PROD_DB
- PROD_APP --> CDN
- PROD_APP --> MAILGUN
- PROD_APP --> RECAPTCHA
- PROD_APP --> OPENAI
- PROD_APP --> OAUTH
-
- %% CI/CD Flow
- GITHUB --> GH_ACTIONS
- GH_ACTIONS --> SONAR
- GH_ACTIONS -->|Deploy to Main| PROD_APP
- GH_ACTIONS -->|Build & Test| DEV_APP
-
- %% Styling
- classDef frontend fill:#e1f5fe,stroke:#01579b,stroke-width:2px
- classDef backend fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
- classDef database fill:#fff3e0,stroke:#e65100,stroke-width:2px
- classDef external fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px
- classDef cicd fill:#fff8e1,stroke:#ff6f00,stroke-width:2px
- classDef user fill:#fce4ec,stroke:#880e4f,stroke-width:2px
-
- class FE frontend
- class DEV_APP,PROD_APP backend
- class DEV_DB,PROD_DB database
- class MAILGUN,RECAPTCHA,SONAR,OPENAI,OAUTH external
- class GITHUB,GH_ACTIONS cicd
- class USER,DEV user
-```
### 인프라 구성 요소
@@ -217,7 +135,7 @@ src/main/java/com/cMall/feedShop/
| **Feed** | ✅ 완료 | 피드 작성, 조회, 좋아요, 댓글 | 높음 |
| **Event** | ✅ 완료 | 이벤트 관리, 검색, 필터링 | 높음 |
| **Store** | ✅ 완료 | 스토어 정보 관리 | 높음 |
-| **AI** | 🔄 진행중 | OpenAI 기반 상품 추천 | 중간 |
+| **AI** | ✅ 완료 | OpenAI 기반 상품 추천 | 높음 |
---
@@ -242,6 +160,7 @@ src/main/java/com/cMall/feedShop/
| **MySQL 8.0** | 메인 데이터베이스 |
| **H2** | 테스트용 인메모리 DB |
| **Google Cloud Storage** | 파일 저장소 |
+| **Google Cloud SQL** | 클라우드 데이터베이스 |
### DevOps & Quality
@@ -259,7 +178,6 @@ src/main/java/com/cMall/feedShop/
| -------------------- | --------------------- |
| **Mailgun** | 이메일 발송 |
| **Google reCAPTCHA** | 봇 방지 |
-| **Google Cloud SQL** | 클라우드 데이터베이스 |
| **OpenAI API** | AI 상품 추천 |
| **Google OAuth2** | 소셜 로그인 |
| **Kakao OAuth2** | 소셜 로그인 |
@@ -277,14 +195,8 @@ src/main/java/com/cMall/feedShop/
### 빠른 시작
-1. **레포지토리 클론**
- ```bash
- git clone https://github.com/ECommerceCommunity/FeedShop_Backend.git
- cd FeedShop_Backend
- ```
-
-2. **환경 설정**
+1. **환경 설정**
```bash
# application.properties.example을 복사하여 설정 파일 생성
@@ -301,13 +213,13 @@ src/main/java/com/cMall/feedShop/
export KAKAO_CLIENT_SECRET=your_kakao_client_secret
```
-3. **데이터베이스 설정**
+2. **데이터베이스 설정**
```sql
CREATE DATABASE feedshop_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
-4. **애플리케이션 실행**
+3. **애플리케이션 실행**
```bash
# 개발 환경
@@ -421,14 +333,15 @@ logging:
### GitHub Actions 워크플로우
-1. **CI Pipeline** (`.github/workflows/ci.yml`)
+**CI Pipeline** (`.github/workflows/ci.yml`)
- - Pull Request 시 자동 실행
- - 빌드, 테스트, 코드 분석 수행
- - SonarCloud 품질 게이트 검증
+- Pull Request 시 자동 실행
+- 빌드, 테스트, 코드 분석 수행
+- SonarCloud 품질 게이트 검증
+
+
+
-2. **Jira 연동** (`.github/workflows/create-jira-issue.yml`)
- - GitHub 이슈 생성 시 Jira 태스크 자동 생성
### 배포 환경
@@ -439,10 +352,12 @@ logging:
### 모니터링
- **애플리케이션 메트릭**: Spring Boot Actuator
-- **로그 관리**: 구조화된 로깅
-- **성능 모니터링**: APM 도구 연동 (구현 예정)
-- **시각화 대시보드**: Grafana (구현 예정)
-- **클라우드 모니터링**: Google Cloud Monitoring (구현 예정)
+- **로그 관리**: 구조화된 로깅(Google Cloud Logging)
+- **시각화 대시보드**: Grafana
+- **클라우드 모니터링**: Google Cloud Monitoring
+
+
+
---
@@ -459,12 +374,12 @@ logging:
### 커밋 메시지 규칙
```
-type(scope): description
+MYCE-001 type/scope: description
-feat(user): 사용자 회원가입 기능 추가
-fix(order): 주문 생성 시 재고 검증 버그 수정
-refactor(product): 상품 조회 로직 개선
-docs(readme): API 문서 업데이트
+feat/user: 사용자 회원가입 기능 추가
+fix/order: 주문 생성 시 재고 검증 버그 수정
+refactor/product: 상품 조회 로직 개선
+docsreadme: API 문서 업데이트
```
### 코드 리뷰 체크리스트
@@ -477,24 +392,10 @@ docs(readme): API 문서 업데이트
---
-## 📝 라이선스
-
-이 프로젝트는 **MIT 라이선스**를 따릅니다. 자세한 내용은 [LICENSE](LICENSE) 파일을 참고하세요.
-
----
-
-## 📞 문의 및 지원
-
-- **이슈 리포트**: [GitHub Issues](https://github.com/ECommerceCommunity/FeedShop_Backend/issues)
-- **기술 문서**: [Wiki](https://github.com/ECommerceCommunity/FeedShop_Backend/wiki)
-- **개발자 가이드**: [개발 가이드 문서](docs/DEVELOPMENT.md)
-
----
-
**FeedShop Backend Team** 🚀
_현대적인 이커머스 플랫폼을 위한 안정적이고 확장 가능한 백엔드 시스템_
-
+
\ No newline at end of file
diff --git a/src/main/java/com/cMall/feedShop/config/DataInitializer.java b/src/main/java/com/cMall/feedShop/config/DataInitializer.java
index a440aad0..81f893bd 100644
--- a/src/main/java/com/cMall/feedShop/config/DataInitializer.java
+++ b/src/main/java/com/cMall/feedShop/config/DataInitializer.java
@@ -20,6 +20,7 @@
import java.time.LocalDateTime;
import java.util.List;
+import java.util.UUID;
/**
* 애플리케이션 시작 시 초기 데이터를 설정하는 클래스
@@ -216,11 +217,12 @@ private void createTestUser(String email, String loginId, UserRole role) {
return;
}
+ String newPassword = UUID.randomUUID().toString();
+
try {
- // 테스트 사용자 생성 - PasswordEncoder를 사용하여 비밀번호 암호화
User testUser = new User(
loginId,
- passwordEncoder.encode("password123!"), // 실제 암호화
+ passwordEncoder.encode(newPassword),
email,
role
);
diff --git a/src/main/java/com/cMall/feedShop/user/application/dto/request/EmailRequest.java b/src/main/java/com/cMall/feedShop/user/application/dto/request/EmailRequest.java
index 3fbd6018..a202561f 100644
--- a/src/main/java/com/cMall/feedShop/user/application/dto/request/EmailRequest.java
+++ b/src/main/java/com/cMall/feedShop/user/application/dto/request/EmailRequest.java
@@ -8,4 +8,8 @@
public class EmailRequest {
@Schema(description = "이메일 주소", example = "user@example.com", required = true)
private String email;
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
}
diff --git a/src/main/java/com/cMall/feedShop/user/application/service/MfaServiceImpl.java b/src/main/java/com/cMall/feedShop/user/application/service/MfaServiceImpl.java
index 0e30d002..8f9a9882 100644
--- a/src/main/java/com/cMall/feedShop/user/application/service/MfaServiceImpl.java
+++ b/src/main/java/com/cMall/feedShop/user/application/service/MfaServiceImpl.java
@@ -284,7 +284,43 @@ public boolean verifyBackupCode(String email, String backupCode) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean verifyBackupCodeInNewTransaction(String email, String backupCode) {
- return verifyBackupCode(email, backupCode);
+ String maskedEmail = LogMaskingUtil.maskEmail(email);
+ String maskedBackupCode = LogMaskingUtil.maskBackupCode(backupCode);
+
+ try {
+ UserMfa userMfa = findUserMfaByEmail(email);
+
+ if (userMfa.getBackupCodes() == null) {
+ log.warn("백업 코드가 설정되지 않음 - 사용자: {}", maskedEmail);
+ return false;
+ }
+
+ List backupCodes = objectMapper.readValue(
+ userMfa.getBackupCodes(),
+ objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)
+ );
+
+ if (backupCodes.contains(backupCode)) {
+ // 사용된 백업 코드 제거
+ backupCodes.remove(backupCode);
+ userMfa.setBackupCodes(objectMapper.writeValueAsString(backupCodes));
+ userMfaRepository.save(userMfa);
+
+ log.info("백업 코드 인증 성공 (새 트랜잭션) - 사용자: {}, 남은 백업 코드 수: {}", maskedEmail, backupCodes.size());
+ return true;
+ }
+
+ log.warn("백업 코드 인증 실패 - 잘못된 코드 - 사용자: {}, 코드: {}", maskedEmail, maskedBackupCode);
+ return false;
+
+ } catch (MfaException e) {
+ log.warn("백업 코드 검증 실패 - 사용자: {}, 코드: {}, 오류: {}", maskedEmail, maskedBackupCode, e.getMessage());
+ return false;
+ } catch (Exception e) {
+ log.error("백업 코드 검증 중 예상치 못한 오류 발생 - 사용자: {}, 코드: {}, 오류: {}",
+ maskedEmail, maskedBackupCode, e.getMessage());
+ return false;
+ }
}
// =========================== Private Helper Methods ===========================
diff --git a/src/main/java/com/cMall/feedShop/user/infrastructure/oauth/CustomOAuth2UserService.java b/src/main/java/com/cMall/feedShop/user/infrastructure/oauth/CustomOAuth2UserService.java
index 6e8a78ce..d71cdb1f 100644
--- a/src/main/java/com/cMall/feedShop/user/infrastructure/oauth/CustomOAuth2UserService.java
+++ b/src/main/java/com/cMall/feedShop/user/infrastructure/oauth/CustomOAuth2UserService.java
@@ -43,7 +43,35 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic
@Transactional(propagation = Propagation.REQUIRES_NEW)
public OAuth2User processAndSaveOAuth2UserInNewTransaction(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
- return processAndSaveOAuth2User(userRequest, oAuth2User);
+ // 1. Get user info (same as before)
+ String registrationId = userRequest.getClientRegistration().getRegistrationId();
+ OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User.getAttributes());
+
+ String email = oAuth2UserInfo.getEmail();
+ if (email == null || email.isEmpty()) {
+ throw new OAuth2AuthenticationException("소셜 로그인 제공자에서 이메일을 가져올 수 없습니다.");
+ }
+
+ // 2. Find or create user (move your existing logic here)
+ UserSocialProvider socialProvider = socialProviderRepository
+ .findByProviderAndProviderSocialUserId(registrationId, oAuth2UserInfo.getId())
+ .orElse(null);
+
+ User user;
+ if (socialProvider != null) {
+ user = handleExistingSocialUser(socialProvider, oAuth2UserInfo);
+ } else {
+ user = handleNewSocialUser(oAuth2UserInfo, registrationId);
+ }
+
+ // 3. Return a CustomOAuth2User object (same as before)
+ return new CustomOAuth2User(
+ oAuth2User,
+ registrationId,
+ oAuth2UserInfo.getId(),
+ oAuth2UserInfo.getEmail(),
+ oAuth2UserInfo.getName()
+ );
}
@Transactional
diff --git a/src/test/java/com/cMall/feedShop/ai/application/service/ProductRecommendationServiceTest.java b/src/test/java/com/cMall/feedShop/ai/application/service/ProductRecommendationServiceTest.java
index fa9c9b68..b68310b3 100644
--- a/src/test/java/com/cMall/feedShop/ai/application/service/ProductRecommendationServiceTest.java
+++ b/src/test/java/com/cMall/feedShop/ai/application/service/ProductRecommendationServiceTest.java
@@ -370,6 +370,7 @@ private ProductRecommendationAIResponse createMockResponse(List productIds
// when
List result = service.recommendProducts(user, prompt, 3);
+
// then
assertThat(result).isNotNull();
assertThat(result.size()).isLessThanOrEqualTo(3);
diff --git a/src/test/java/com/cMall/feedShop/common/util/LogMaskingUtilTest.java b/src/test/java/com/cMall/feedShop/common/util/LogMaskingUtilTest.java
new file mode 100644
index 00000000..e6f06d22
--- /dev/null
+++ b/src/test/java/com/cMall/feedShop/common/util/LogMaskingUtilTest.java
@@ -0,0 +1,387 @@
+package com.cMall.feedShop.common.util;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("LogMaskingUtil 테스트")
+class LogMaskingUtilTest {
+
+ @Test
+ @DisplayName("이메일 마스킹 - 정상적인 이메일")
+ void maskEmail_NormalEmail() {
+ // given
+ String email = "user@example.com";
+
+ // when
+ String result = LogMaskingUtil.maskEmail(email);
+
+ // then
+ assertThat(result).isEqualTo("u**r@example.com");
+ }
+
+ @Test
+ @DisplayName("이메일 마스킹 - 짧은 로컬 파트")
+ void maskEmail_ShortLocalPart() {
+ // given
+ String email = "ab@example.com";
+
+ // when
+ String result = LogMaskingUtil.maskEmail(email);
+
+ // then
+ assertThat(result).isEqualTo("a*@example.com");
+ }
+
+ @Test
+ @DisplayName("이메일 마스킹 - 매우 짧은 로컬 파트")
+ void maskEmail_VeryShortLocalPart() {
+ // given
+ String email = "a@example.com";
+
+ // when
+ String result = LogMaskingUtil.maskEmail(email);
+
+ // then
+ assertThat(result).isEqualTo("a*@example.com");
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {"invalid-email", "no-at-sign"})
+ @DisplayName("이메일 마스킹 - 잘못된 이메일 형식")
+ void maskEmail_InvalidEmail(String email) {
+ // when
+ String result = LogMaskingUtil.maskEmail(email);
+
+ // then
+ assertThat(result).isEqualTo(email);
+ }
+
+ @Test
+ @DisplayName("전화번호 마스킹 - 11자리 전화번호")
+ void maskPhoneNumber_11Digits() {
+ // given
+ String phoneNumber = "010-1234-5678";
+
+ // when
+ String result = LogMaskingUtil.maskPhoneNumber(phoneNumber);
+
+ // then
+ assertThat(result).isEqualTo("***-****-5678");
+ }
+
+ @Test
+ @DisplayName("전화번호 마스킹 - 10자리 전화번호")
+ void maskPhoneNumber_10Digits() {
+ // given
+ String phoneNumber = "02-123-4567";
+
+ // when
+ String result = LogMaskingUtil.maskPhoneNumber(phoneNumber);
+
+ // then
+ assertThat(result).isEqualTo("*****4567");
+ }
+
+ @Test
+ @DisplayName("전화번호 마스킹 - 하이픈 없는 전화번호")
+ void maskPhoneNumber_NoHyphens() {
+ // given
+ String phoneNumber = "01012345678";
+
+ // when
+ String result = LogMaskingUtil.maskPhoneNumber(phoneNumber);
+
+ // then
+ assertThat(result).isEqualTo("*******5678");
+ }
+
+ @Test
+ @DisplayName("전화번호 마스킹 - 짧은 전화번호")
+ void maskPhoneNumber_ShortNumber() {
+ // given
+ String phoneNumber = "123";
+
+ // when
+ String result = LogMaskingUtil.maskPhoneNumber(phoneNumber);
+
+ // then
+ assertThat(result).isEqualTo("123");
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {"123", "abc"})
+ @DisplayName("전화번호 마스킹 - 잘못된 전화번호")
+ void maskPhoneNumber_InvalidPhoneNumber(String phoneNumber) {
+ // when
+ String result = LogMaskingUtil.maskPhoneNumber(phoneNumber);
+
+ // then
+ assertThat(result).isEqualTo(phoneNumber);
+ }
+
+ @Test
+ @DisplayName("토큰 마스킹 - 긴 토큰")
+ void maskToken_LongToken() {
+ // given
+ String token = "abc123def456ghi789";
+
+ // when
+ String result = LogMaskingUtil.maskToken(token);
+
+ // then
+ assertThat(result).isEqualTo("abc************789");
+ }
+
+ @Test
+ @DisplayName("토큰 마스킹 - 중간 길이 토큰")
+ void maskToken_MediumToken() {
+ // given
+ String token = "abc123def";
+
+ // when
+ String result = LogMaskingUtil.maskToken(token);
+
+ // then
+ assertThat(result).isEqualTo("abc***def");
+ }
+
+ @Test
+ @DisplayName("토큰 마스킹 - 짧은 토큰")
+ void maskToken_ShortToken() {
+ // given
+ String token = "abc123";
+
+ // when
+ String result = LogMaskingUtil.maskToken(token);
+
+ // then
+ assertThat(result).isEqualTo("ab**23");
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {"123", "ab", "a"})
+ @DisplayName("토큰 마스킹 - 잘못된 토큰")
+ void maskToken_InvalidToken(String token) {
+ // when
+ String result = LogMaskingUtil.maskToken(token);
+
+ // then
+ assertThat(result).isEqualTo(token);
+ }
+
+ @Test
+ @DisplayName("MFA 토큰 마스킹 - 정상적인 6자리 토큰")
+ void maskMfaToken_ValidToken() {
+ // given
+ String token = "123456";
+
+ // when
+ String result = LogMaskingUtil.maskMfaToken(token);
+
+ // then
+ assertThat(result).isEqualTo("12****");
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {"12345", "1234567", "12", "123"})
+ @DisplayName("MFA 토큰 마스킹 - 잘못된 토큰")
+ void maskMfaToken_InvalidToken(String token) {
+ // when
+ String result = LogMaskingUtil.maskMfaToken(token);
+
+ // then
+ assertThat(result).isEqualTo(token);
+ }
+
+ @Test
+ @DisplayName("백업 코드 마스킹 - 정상적인 8자리 코드")
+ void maskBackupCode_ValidCode() {
+ // given
+ String code = "12345678";
+
+ // when
+ String result = LogMaskingUtil.maskBackupCode(code);
+
+ // then
+ assertThat(result).isEqualTo("12****78");
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {"1234567", "123456789", "12", "123"})
+ @DisplayName("백업 코드 마스킹 - 잘못된 코드")
+ void maskBackupCode_InvalidCode(String code) {
+ // when
+ String result = LogMaskingUtil.maskBackupCode(code);
+
+ // then
+ assertThat(result).isEqualTo(code);
+ }
+
+ @Test
+ @DisplayName("사용자 ID 마스킹 - 긴 ID")
+ void maskUserId_LongId() {
+ // given
+ Long userId = 12345L;
+
+ // when
+ String result = LogMaskingUtil.maskUserId(userId);
+
+ // then
+ assertThat(result).isEqualTo("1****");
+ }
+
+ @Test
+ @DisplayName("사용자 ID 마스킹 - 짧은 ID")
+ void maskUserId_ShortId() {
+ // given
+ Long userId = 123L;
+
+ // when
+ String result = LogMaskingUtil.maskUserId(userId);
+
+ // then
+ assertThat(result).isEqualTo("1**");
+ }
+
+ @Test
+ @DisplayName("사용자 ID 마스킹 - 한 자리 ID")
+ void maskUserId_SingleDigitId() {
+ // given
+ Long userId = 5L;
+
+ // when
+ String result = LogMaskingUtil.maskUserId(userId);
+
+ // then
+ assertThat(result).isEqualTo("5");
+ }
+
+ @Test
+ @DisplayName("사용자 ID 마스킹 - null ID")
+ void maskUserId_NullId() {
+ // when
+ String result = LogMaskingUtil.maskUserId(null);
+
+ // then
+ assertThat(result).isNull();
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "user@example.com, email, u**r@example.com",
+ "010-1234-5678, phone, ***-****-5678",
+ "abc123def456, token, abc******456",
+ "123456, mfa, 12****",
+ "12345678, backup, 12****78",
+ "unknown, unknown, unknown"
+ })
+ @DisplayName("민감 정보 마스킹 - 타입별 마스킹")
+ void maskSensitiveInfo_ByType(String input, String type, String expected) {
+ // when
+ String result = LogMaskingUtil.maskSensitiveInfo(input, type);
+
+ // then
+ assertThat(result).isEqualTo(expected);
+ }
+
+ @Test
+ @DisplayName("민감 정보 마스킹 - null 입력")
+ void maskSensitiveInfo_NullInput() {
+ // when
+ String result = LogMaskingUtil.maskSensitiveInfo(null, "email");
+
+ // then
+ assertThat(result).isNull();
+ }
+
+ @Test
+ @DisplayName("민감 정보 마스킹 - 대소문자 구분 없는 타입")
+ void maskSensitiveInfo_CaseInsensitiveType() {
+ // given
+ String email = "user@example.com";
+
+ // when
+ String result1 = LogMaskingUtil.maskSensitiveInfo(email, "EMAIL");
+ String result2 = LogMaskingUtil.maskSensitiveInfo(email, "Email");
+
+ // then
+ assertThat(result1).isEqualTo("u**r@example.com");
+ assertThat(result2).isEqualTo("u**r@example.com");
+ }
+
+ @Test
+ @DisplayName("이메일 마스킹 - 다양한 이메일 형식")
+ void maskEmail_VariousFormats() {
+ // given & when & then
+ assertThat(LogMaskingUtil.maskEmail("test@example.com")).isEqualTo("t**t@example.com");
+ assertThat(LogMaskingUtil.maskEmail("admin@company.co.kr")).isEqualTo("a***n@company.co.kr");
+ assertThat(LogMaskingUtil.maskEmail("user123@domain.org")).isEqualTo("u*****3@domain.org");
+ assertThat(LogMaskingUtil.maskEmail("a@b.c")).isEqualTo("a*@b.c");
+ assertThat(LogMaskingUtil.maskEmail("ab@c.d")).isEqualTo("a*@c.d");
+ }
+
+ @Test
+ @DisplayName("전화번호 마스킹 - 다양한 전화번호 형식")
+ void maskPhoneNumber_VariousFormats() {
+ // given & when & then
+ assertThat(LogMaskingUtil.maskPhoneNumber("010-1234-5678")).isEqualTo("***-****-5678");
+ assertThat(LogMaskingUtil.maskPhoneNumber("02-123-4567")).isEqualTo("*****4567");
+ assertThat(LogMaskingUtil.maskPhoneNumber("031-123-4567")).isEqualTo("***-***-4567");
+ assertThat(LogMaskingUtil.maskPhoneNumber("01012345678")).isEqualTo("*******5678");
+ assertThat(LogMaskingUtil.maskPhoneNumber("0212345678")).isEqualTo("******5678");
+ }
+
+ @Test
+ @DisplayName("토큰 마스킹 - 다양한 토큰 길이")
+ void maskToken_VariousLengths() {
+ // given & when & then
+ assertThat(LogMaskingUtil.maskToken("abc123")).isEqualTo("ab**23"); // 6자리
+ assertThat(LogMaskingUtil.maskToken("abc123def")).isEqualTo("abc***def"); // 9자리
+ assertThat(LogMaskingUtil.maskToken("abc123def456")).isEqualTo("abc******456"); // 12자리
+ assertThat(LogMaskingUtil.maskToken("abc123def456ghi789")).isEqualTo("abc************789"); // 18자리
+ }
+
+ @Test
+ @DisplayName("사용자 ID 마스킹 - 다양한 ID 길이")
+ void maskUserId_VariousLengths() {
+ // given & when & then
+ assertThat(LogMaskingUtil.maskUserId(1L)).isEqualTo("1");
+ assertThat(LogMaskingUtil.maskUserId(12L)).isEqualTo("1*");
+ assertThat(LogMaskingUtil.maskUserId(123L)).isEqualTo("1**");
+ assertThat(LogMaskingUtil.maskUserId(1234L)).isEqualTo("1***");
+ assertThat(LogMaskingUtil.maskUserId(12345L)).isEqualTo("1****");
+ assertThat(LogMaskingUtil.maskUserId(123456L)).isEqualTo("1*****");
+ }
+
+ @Test
+ @DisplayName("엣지 케이스 - 빈 문자열과 공백")
+ void edgeCases_EmptyAndWhitespace() {
+ // given & when & then
+ assertThat(LogMaskingUtil.maskEmail("")).isEqualTo("");
+ assertThat(LogMaskingUtil.maskEmail(" ")).isEqualTo(" ");
+ assertThat(LogMaskingUtil.maskPhoneNumber("")).isEqualTo("");
+ assertThat(LogMaskingUtil.maskPhoneNumber(" ")).isEqualTo(" ");
+ assertThat(LogMaskingUtil.maskToken("")).isEqualTo("");
+ assertThat(LogMaskingUtil.maskToken(" ")).isEqualTo(" ");
+ }
+
+ @Test
+ @DisplayName("엣지 케이스 - 특수 문자 포함")
+ void edgeCases_SpecialCharacters() {
+ // given & when & then
+ assertThat(LogMaskingUtil.maskEmail("user+tag@example.com")).isEqualTo("u******g@example.com");
+ assertThat(LogMaskingUtil.maskPhoneNumber("010-1234-5678 (mobile)")).isEqualTo("***-****-5678");
+ assertThat(LogMaskingUtil.maskToken("abc-123_def.456")).isEqualTo("abc*********456");
+ }
+}
diff --git a/src/test/java/com/cMall/feedShop/user/presentation/UserAuthControllerTest.java b/src/test/java/com/cMall/feedShop/user/presentation/UserAuthControllerTest.java
new file mode 100644
index 00000000..3ded6ddc
--- /dev/null
+++ b/src/test/java/com/cMall/feedShop/user/presentation/UserAuthControllerTest.java
@@ -0,0 +1,439 @@
+package com.cMall.feedShop.user.presentation;
+
+import com.cMall.feedShop.common.captcha.RecaptchaVerificationService;
+import com.cMall.feedShop.common.dto.ApiResponse;
+import com.cMall.feedShop.common.exception.BusinessException;
+import com.cMall.feedShop.common.exception.ErrorCode;
+import com.cMall.feedShop.common.exception.GlobalExceptionHandler;
+import com.cMall.feedShop.user.application.dto.request.EmailRequest;
+import com.cMall.feedShop.user.application.dto.request.PasswordResetConfirmRequest;
+import com.cMall.feedShop.user.application.dto.request.UserLoginRequest;
+import com.cMall.feedShop.user.application.dto.request.UserSignUpRequest;
+import com.cMall.feedShop.user.application.dto.response.MfaStatusResponse;
+import com.cMall.feedShop.user.application.dto.response.UserLoginResponse;
+import com.cMall.feedShop.user.application.dto.response.UserResponse;
+import com.cMall.feedShop.user.application.service.MfaService;
+import com.cMall.feedShop.user.application.service.UserAuthService;
+import com.cMall.feedShop.user.application.service.UserService;
+import com.cMall.feedShop.user.domain.enums.UserRole;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("UserAuthController 테스트")
+class UserAuthControllerTest {
+
+ @Mock
+ private UserService userService;
+
+ @Mock
+ private UserAuthService userAuthService;
+
+ @Mock
+ private RecaptchaVerificationService recaptchaService;
+
+ @Mock
+ private MfaService mfaService;
+
+ @InjectMocks
+ private UserAuthController userAuthController;
+
+ private MockMvc mockMvc;
+ private ObjectMapper objectMapper;
+
+ @BeforeEach
+ void setUp() {
+ mockMvc = MockMvcBuilders.standaloneSetup(userAuthController)
+ .setControllerAdvice(new GlobalExceptionHandler())
+ .build();
+ objectMapper = new ObjectMapper();
+ }
+
+ @Test
+ @DisplayName("회원가입 - 성공")
+ void signUp_Success() throws Exception {
+ // given
+ UserSignUpRequest request = new UserSignUpRequest();
+ request.setEmail("test@example.com");
+ request.setPassword("password123!");
+ request.setConfirmPassword("password123!");
+ request.setName("테스트 사용자");
+ request.setPhone("010-1234-5678");
+
+ UserResponse expectedResponse = UserResponse.builder()
+ .userId(1L)
+ .email("test@example.com")
+ .username("테스트 사용자")
+ .role(UserRole.USER)
+ .build();
+
+ given(userService.signUp(any(UserSignUpRequest.class)))
+ .willReturn(expectedResponse);
+
+ // when & then
+ mockMvc.perform(post("/api/auth/signup")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.data.userId").value(1))
+ .andExpect(jsonPath("$.data.email").value("test@example.com"))
+ .andExpect(jsonPath("$.data.username").value("테스트 사용자"));
+
+ verify(userService, times(1)).signUp(any(UserSignUpRequest.class));
+ }
+
+ @Test
+ @DisplayName("회원가입 - 실패 (잘못된 요청)")
+ void signUp_Failure_InvalidRequest() throws Exception {
+ // given
+ UserSignUpRequest request = new UserSignUpRequest();
+ // 필수 필드 누락
+
+ // when & then
+ mockMvc.perform(post("/api/auth/signup")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isBadRequest());
+
+ verify(userService, never()).signUp(any(UserSignUpRequest.class));
+ }
+
+ @Test
+ @DisplayName("로그인 - 성공 (MFA 비활성화)")
+ void login_Success_WithoutMfa() throws Exception {
+ // given
+ UserLoginRequest request = new UserLoginRequest();
+ request.setEmail("test@example.com");
+ request.setPassword("password123");
+ request.setRecaptchaToken("valid-token");
+
+ UserLoginResponse loginResponse = UserLoginResponse.builder()
+ .loginId("testuser")
+ .role(UserRole.USER)
+ .nickname("테스트")
+ .token("jwt-token")
+ .build();
+
+ MfaStatusResponse mfaStatus = MfaStatusResponse.builder()
+ .enabled(false)
+ .email("test@example.com")
+ .build();
+
+ doNothing().when(recaptchaService).verifyRecaptcha(anyString(), anyString());
+ given(userAuthService.login(any(UserLoginRequest.class)))
+ .willReturn(loginResponse);
+ given(mfaService.getMfaStatus(anyString()))
+ .willReturn(mfaStatus);
+
+ // when & then
+ mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.data.loginId").value("testuser"))
+ .andExpect(jsonPath("$.data.token").value("jwt-token"))
+ .andExpect(jsonPath("$.data.requiresMfa").value(false));
+
+ verify(recaptchaService, times(1)).verifyRecaptcha(anyString(), anyString());
+ verify(userAuthService, times(1)).login(any(UserLoginRequest.class));
+ verify(mfaService, times(1)).getMfaStatus(anyString());
+ }
+
+ @Test
+ @DisplayName("로그인 - 성공 (MFA 활성화)")
+ void login_Success_WithMfa() throws Exception {
+ // given
+ UserLoginRequest request = new UserLoginRequest();
+ request.setEmail("test@example.com");
+ request.setPassword("password123");
+ request.setRecaptchaToken("valid-token");
+
+ UserLoginResponse loginResponse = UserLoginResponse.builder()
+ .loginId("testuser")
+ .role(UserRole.USER)
+ .nickname("테스트")
+ .token("jwt-token")
+ .build();
+
+ MfaStatusResponse mfaStatus = MfaStatusResponse.builder()
+ .enabled(true)
+ .email("test@example.com")
+ .build();
+
+ doNothing().when(recaptchaService).verifyRecaptcha(anyString(), anyString());
+ given(userAuthService.login(any(UserLoginRequest.class)))
+ .willReturn(loginResponse);
+ given(mfaService.getMfaStatus(anyString()))
+ .willReturn(mfaStatus);
+
+ // when & then
+ mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.data.loginId").value("testuser"))
+ .andExpect(jsonPath("$.data.requiresMfa").value(true))
+ .andExpect(jsonPath("$.data.tempToken").value("jwt-token"));
+
+ verify(recaptchaService, times(1)).verifyRecaptcha(anyString(), anyString());
+ verify(userAuthService, times(1)).login(any(UserLoginRequest.class));
+ verify(mfaService, times(1)).getMfaStatus(anyString());
+ }
+
+ @Test
+ @DisplayName("로그인 - 실패 (reCAPTCHA 검증 실패)")
+ void login_Failure_RecaptchaVerificationFailed() throws Exception {
+ // given
+ UserLoginRequest request = new UserLoginRequest();
+ request.setEmail("test@example.com");
+ request.setPassword("password123");
+ request.setRecaptchaToken("invalid-token");
+
+ doThrow(new BusinessException(ErrorCode.RECAPTCHA_VERIFICATION_FAILED))
+ .when(recaptchaService).verifyRecaptcha(anyString(), anyString());
+
+ // when & then
+ mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isBadRequest());
+
+ verify(recaptchaService, times(1)).verifyRecaptcha(anyString(), anyString());
+ verify(userAuthService, never()).login(any(UserLoginRequest.class));
+ }
+
+ @Test
+ @DisplayName("이메일 인증 - 성공")
+ void verifyEmail_Success() throws Exception {
+ // given
+ String token = "valid-email-token";
+ doNothing().when(userService).verifyEmail(anyString());
+
+ // when & then
+ mockMvc.perform(get("/api/auth/verify-email")
+ .param("token", token))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.data").value("이메일 인증이 완료되었습니다. 이제 로그인할 수 있습니다."));
+
+ verify(userService, times(1)).verifyEmail(token);
+ }
+
+ @Test
+ @DisplayName("이메일 인증 - 실패 (잘못된 토큰)")
+ void verifyEmail_Failure_InvalidToken() throws Exception {
+ // given
+ String token = "invalid-token";
+ doThrow(new BusinessException(ErrorCode.INVALID_VERIFICATION_TOKEN))
+ .when(userService).verifyEmail(anyString());
+
+ // when & then
+ mockMvc.perform(get("/api/auth/verify-email")
+ .param("token", token))
+ .andExpect(status().isBadRequest());
+
+ verify(userService, times(1)).verifyEmail(token);
+ }
+
+ @Test
+ @DisplayName("계정 찾기 - 성공")
+ void findAccountByNameAndPhone_Success() throws Exception {
+ // given
+ String username = "홍길동";
+ String phoneNumber = "010-1234-5678";
+
+ List expectedAccounts = Arrays.asList(
+ UserResponse.builder()
+ .userId(1L)
+ .email("h***@example.com")
+ .username("홍길동")
+ .role(UserRole.USER)
+ .build()
+ );
+
+ given(userService.findByUsernameAndPhoneNumber(anyString(), anyString()))
+ .willReturn(expectedAccounts);
+
+ // when & then
+ mockMvc.perform(get("/api/auth/find-account")
+ .param("username", username)
+ .param("phoneNumber", phoneNumber))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.data[0].userId").value(1))
+ .andExpect(jsonPath("$.data[0].email").value("h***@example.com"))
+ .andExpect(jsonPath("$.data[0].username").value("홍길동"));
+
+ verify(userService, times(1)).findByUsernameAndPhoneNumber(username, phoneNumber);
+ }
+
+ @Test
+ @DisplayName("계정 찾기 - 실패 (계정 없음)")
+ void findAccountByNameAndPhone_Failure_AccountNotFound() throws Exception {
+ // given
+ String username = "존재하지않는사용자";
+ String phoneNumber = "010-9999-9999";
+
+ given(userService.findByUsernameAndPhoneNumber(anyString(), anyString()))
+ .willThrow(new BusinessException(ErrorCode.USER_NOT_FOUND));
+
+ // when & then
+ mockMvc.perform(get("/api/auth/find-account")
+ .param("username", username)
+ .param("phoneNumber", phoneNumber))
+ .andExpect(status().isNotFound());
+
+ verify(userService, times(1)).findByUsernameAndPhoneNumber(username, phoneNumber);
+ }
+
+ @Test
+ @DisplayName("비밀번호 재설정 요청 - 성공")
+ void forgotPassword_Success() throws Exception {
+ // given
+ EmailRequest request = new EmailRequest();
+ request.setEmail("test@example.com");
+
+ doNothing().when(userAuthService).requestPasswordReset(anyString());
+
+ // when & then
+ mockMvc.perform(post("/api/auth/forgot-password")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.data").value("비밀번호 재설정 이메일이 발송되었습니다."));
+
+ verify(userAuthService, times(1)).requestPasswordReset("test@example.com");
+ }
+
+ @Test
+ @DisplayName("비밀번호 재설정 요청 - 실패 (존재하지 않는 이메일)")
+ void forgotPassword_Failure_EmailNotFound() throws Exception {
+ // given
+ EmailRequest request = new EmailRequest();
+ request.setEmail("nonexistent@example.com");
+
+ doThrow(new BusinessException(ErrorCode.USER_NOT_FOUND))
+ .when(userAuthService).requestPasswordReset(anyString());
+
+ // when & then
+ mockMvc.perform(post("/api/auth/forgot-password")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isNotFound());
+
+ verify(userAuthService, times(1)).requestPasswordReset("nonexistent@example.com");
+ }
+
+ @Test
+ @DisplayName("비밀번호 재설정 토큰 검증 - 성공")
+ void validatePasswordResetToken_Success() throws Exception {
+ // given
+ String token = "valid-reset-token";
+ doNothing().when(userAuthService).validatePasswordResetToken(anyString());
+
+ // when & then
+ mockMvc.perform(get("/api/auth/reset-password/validate")
+ .param("token", token))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.data").value("토큰이 유효합니다."));
+
+ verify(userAuthService, times(1)).validatePasswordResetToken(token);
+ }
+
+ @Test
+ @DisplayName("비밀번호 재설정 토큰 검증 - 실패 (잘못된 토큰)")
+ void validatePasswordResetToken_Failure_InvalidToken() throws Exception {
+ // given
+ String token = "invalid-reset-token";
+ doThrow(new BusinessException(ErrorCode.INVALID_TOKEN))
+ .when(userAuthService).validatePasswordResetToken(anyString());
+
+ // when & then
+ mockMvc.perform(get("/api/auth/reset-password/validate")
+ .param("token", token))
+ .andExpect(status().isBadRequest());
+
+ verify(userAuthService, times(1)).validatePasswordResetToken(token);
+ }
+
+ @Test
+ @DisplayName("비밀번호 재설정 - 성공")
+ void resetPassword_Success() throws Exception {
+ // given
+ PasswordResetConfirmRequest request = new PasswordResetConfirmRequest();
+ request.setToken("valid-reset-token");
+ request.setNewPassword("newPassword123");
+
+ doNothing().when(userAuthService).resetPassword(anyString(), anyString());
+
+ // when & then
+ mockMvc.perform(post("/api/auth/reset-password")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.data").value("비밀번호가 성공적으로 재설정되었습니다."));
+
+ verify(userAuthService, times(1)).resetPassword("valid-reset-token", "newPassword123");
+ }
+
+ @Test
+ @DisplayName("비밀번호 재설정 - 실패 (잘못된 토큰)")
+ void resetPassword_Failure_InvalidToken() throws Exception {
+ // given
+ PasswordResetConfirmRequest request = new PasswordResetConfirmRequest();
+ request.setToken("invalid-reset-token");
+ request.setNewPassword("newPassword123");
+
+ doThrow(new BusinessException(ErrorCode.INVALID_TOKEN))
+ .when(userAuthService).resetPassword(anyString(), anyString());
+
+ // when & then
+ mockMvc.perform(post("/api/auth/reset-password")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isBadRequest());
+
+ verify(userAuthService, times(1)).resetPassword("invalid-reset-token", "newPassword123");
+ }
+
+ @Test
+ @DisplayName("비밀번호 재설정 - 실패 (잘못된 요청)")
+ void resetPassword_Failure_InvalidRequest() throws Exception {
+ // given
+ PasswordResetConfirmRequest request = new PasswordResetConfirmRequest();
+ // 필수 필드 누락
+
+ // when & then
+ mockMvc.perform(post("/api/auth/reset-password")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isBadRequest());
+
+ verify(userAuthService, never()).resetPassword(anyString(), anyString());
+ }
+}