diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..da6331c2d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: Spring Boot CI Pipeline + +on: + push: + branches: [ "main", "develop" ] + pull_request: + branches: [ "main", "develop" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'gradle' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Clean build directory + run: ./gradlew clean + + - name: Run Tests + run: ./gradlew test --continue + env: + SPRING_PROFILES_ACTIVE: test + + + - name: Generate Test Coverage Report + run: ./gradlew jacocoTestReport + + - name: Build Project (without tests) # 테스트를 제외하고 프로젝트를 빌드합니다. + # 이 단계에서는 application.properties가 기본으로 사용되며, + # 만약 해당 파일에 환경 변수(DB_HOST, SERVER_PORT 등)가 정의되어 있고, + # 빌드 시에 해당 변수들이 필요하다면 아래 'env' 섹션을 추가해야 합니다. + # 일반적으로 JAR/WAR 파일 생성 시에는 플레이스홀더를 포함한 채로 빌드하고, + # 실제 실행 환경에서 환경 변수를 주입하는 것이 일반적입니다. + # 하지만 만약 빌드 자체가 특정 환경 변수를 필요로 한다면: + # env: + # DB_HOST: ${{ secrets.DB_HOST_PROD }} # 예시: 실제 운영 DB 호스트 + # DB_PORT: ${{ secrets.DB_PORT_PROD }} + # SPRING_PROFILES_ACTIVE: production # 만약 빌드 시에 'production' 프로파일을 사용하고 싶다면 + run: ./gradlew build -x test + + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: build/test-results/test/*.xml + + - name: Upload Test Coverage # 테스트 커버리지 보고서 아티팩트를 업로드합니다. + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-coverage + path: build/reports/jacoco/test/jacocoTestReport.xml + + - name: Analyze with SonarQube + run: | + ./gradlew sonarqube \ + -Dsonar.projectKey=ECommerceCommunity_FeedShop_Backend \ + -Dsonar.projectName="FeedShop_Backend" \ + -Dsonar.token=${{ secrets.SONAR_TOKEN }} \ + -Dsonar.organization=ecommercecommunity # + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} diff --git a/.gitignore b/.gitignore index 303893d52..fb6172d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,14 @@ Thumbs.db # Windows system files *.zip # Archive files (e.g., generated distributions) *.tar.gz # Archive files +# Additions for sensitive configuration files +.env +application.properties +application-test.properties +application.yml +logs/ +src/main/resources/keystore.p12 + # Gradle .gradle/ build/ @@ -38,3 +46,7 @@ out/ # VS Code .vscode/ + + +# Keystore files +keystore.p12 diff --git a/Formulae b/Formulae new file mode 100644 index 000000000..e69de29bb diff --git a/README.md b/README.md index 1b331b758..2913d929c 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ -# ShopChat_Backend \ No newline at end of file +# ShopChat_Backend +Project - Spring Backend Develop diff --git a/Searching b/Searching new file mode 100644 index 000000000..e69de29bb diff --git a/build.gradle b/build.gradle index 30ffb4384..30af87941 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,8 @@ plugins { id 'java' id 'org.springframework.boot' version '3.3.12' id 'io.spring.dependency-management' version '1.1.7' + id 'org.sonarqube' version '5.1.0.4882' + id 'jacoco' } group = 'com.cMall' @@ -24,24 +26,80 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' - compileOnly 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + compileOnly 'org.projectlombok:lombok' // devtools 배포 시에는 삭제 - developmentOnly 'org.springframework.boot:spring-boot-devtools' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-aop' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'com.h2database:h2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // JWT 라이브러리 추가 + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + implementation 'com.google.cloud.sql:mysql-socket-factory-connector-j-8:1.15.0' - runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { useJUnitPlatform() + finalizedBy jacocoTestReport + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + showExceptions true + exceptionFormat "full" // 스택 트레이스를 전체 출력 + showStandardStreams true // System.out, System.err로 출력되는 내용도 포함 + } +} + +jacoco { + toolVersion = "0.8.11" // 최신 Jacoco 버전 확인 후 적용 } + +jacocoTestReport { + dependsOn test // jacocoTestReport 태스크가 test 태스크 실행 이후에 실행되도록 의존성 설정 + reports { + xml.required = true // SonarCloud가 읽을 수 있도록 XML 리포트 필수 + csv.required = false + html.required = true // 사람이 읽기 쉬운 HTML 리포트도 생성 (선택 사항) + } + // 보고서가 생성될 경로 설정 (SonarCloud에서 이 경로를 참조하게 됨) + // destinationFile = file("${buildDir}/reports/jacoco/test/jacocoTestReport.xml") // 기본 경로와 동일하면 명시하지 않아도 됨 + // html.outputLocation = file("${buildDir}/reports/jacoco/html") // HTML 리포트 출력 경로 (선택 사항) +} + +// SonarCloud 설정 추가 +sonar { + properties { + property "sonar.projectKey", "ECommerceCommunity_FeedShop_Backend" + property "sonar.organization", "ecommercecommunity" + property "sonar.host.url", "https://sonarcloud.io" + property "sonar.token", System.getenv("SONAR_TOKEN") + + // 코드 커버리지를 Jacoco 리포트와 연동 + property "sonar.java.coveragePlugin", "jacoco" + // Jacoco XML 리포트 파일의 경로를 SonarCloud에 알려줍니다. + // Jacoco 설정을 통해 생성되는 기본 경로와 일치하는지 확인하세요. + property "sonar.coverage.jacoco.xmlReportPaths", "${buildDir}/reports/jacoco/test/jacocoTestReport.xml" + + // 분석에서 제외할 파일이나 디렉토리를 지정할 수 있습니다. (선택 사항) + // property "sonar.exclusions", "**/generated/**, **/*.html" + // property "sonar.test.exclusions", "**/*Test.java" // 테스트 파일 제외 예시 + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3104cb53f..12cdbb1db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,14 @@ -version: '3.8' - services: mysql: image: mysql:8.0 container_name: mysql-db ports: - - "3306:3306" + - "${DB_PORT:-3306}:3306" environment: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: shopchat - MYSQL_USER: cmall - MYSQL_PASSWORD: pass + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} volumes: - mysql_data:/var/lib/mysql networks: @@ -20,4 +18,4 @@ volumes: mysql_data: networks: - springboot-network: + springboot-network: \ No newline at end of file diff --git a/dockerfile b/dockerfile new file mode 100644 index 000000000..ea9b51feb --- /dev/null +++ b/dockerfile @@ -0,0 +1,4 @@ +FROM eclipse-temurin:17-jdk-alpine +WORKDIR /app +COPY build/libs/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index fd229ab0c..26d55fe86 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'shopChat' +rootProject.name = 'feedShop' diff --git a/src/main/java/com/cMall/feedShop/FeedShopApplication.java b/src/main/java/com/cMall/feedShop/FeedShopApplication.java new file mode 100644 index 000000000..133fd2e16 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/FeedShopApplication.java @@ -0,0 +1,17 @@ +package com.cMall.feedShop; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableAspectJAutoProxy +@EnableJpaAuditing +public class FeedShopApplication { + + public static void main(String[] args) { + SpringApplication.run(FeedShopApplication.class, args); + } +} + diff --git a/src/main/java/com/cMall/feedShop/annotation/CustomEncryption.java b/src/main/java/com/cMall/feedShop/annotation/CustomEncryption.java new file mode 100644 index 000000000..2e0690534 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/annotation/CustomEncryption.java @@ -0,0 +1,11 @@ +package com.cMall.feedShop.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) // 런타임까지 유지되어 리플렉션으로 읽을 수 있도록 함 +public @interface CustomEncryption { +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/cart/application/CartService.java b/src/main/java/com/cMall/feedShop/cart/application/CartService.java new file mode 100644 index 000000000..4184eaf67 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/application/CartService.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.application; + +public class CartService { +} diff --git a/src/main/java/com/cMall/feedShop/cart/application/dto/CartItemInfo.java b/src/main/java/com/cMall/feedShop/cart/application/dto/CartItemInfo.java new file mode 100644 index 000000000..9ba5ae682 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/application/dto/CartItemInfo.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.application.dto; + +public class CartItemInfo { +} diff --git a/src/main/java/com/cMall/feedShop/cart/application/dto/request/CartCreateRequest.java b/src/main/java/com/cMall/feedShop/cart/application/dto/request/CartCreateRequest.java new file mode 100644 index 000000000..17339d985 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/application/dto/request/CartCreateRequest.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.application.dto.request; + +public class CartCreateRequest { +} diff --git a/src/main/java/com/cMall/feedShop/cart/application/dto/request/CartItemCreateRequest.java b/src/main/java/com/cMall/feedShop/cart/application/dto/request/CartItemCreateRequest.java new file mode 100644 index 000000000..8f3e7e4d7 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/application/dto/request/CartItemCreateRequest.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.application.dto.request; + +public class CartItemCreateRequest { +} diff --git a/src/main/java/com/cMall/feedShop/cart/application/dto/request/CartItemUpdateRequest.java b/src/main/java/com/cMall/feedShop/cart/application/dto/request/CartItemUpdateRequest.java new file mode 100644 index 000000000..7a8464d32 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/application/dto/request/CartItemUpdateRequest.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.application.dto.request; + +public class CartItemUpdateRequest { +} diff --git a/src/main/java/com/cMall/feedShop/cart/application/dto/response/CartCreateResponse.java b/src/main/java/com/cMall/feedShop/cart/application/dto/response/CartCreateResponse.java new file mode 100644 index 000000000..ef6c7e539 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/application/dto/response/CartCreateResponse.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.application.dto.response; + +public class CartCreateResponse { +} diff --git a/src/main/java/com/cMall/feedShop/cart/application/exception/CartException.java b/src/main/java/com/cMall/feedShop/cart/application/exception/CartException.java new file mode 100644 index 000000000..fa7a14634 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/application/exception/CartException.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.application.exception; + +public class CartException { +} diff --git a/src/main/java/com/cMall/feedShop/cart/application/exception/CartItemException.java b/src/main/java/com/cMall/feedShop/cart/application/exception/CartItemException.java new file mode 100644 index 000000000..cd21084f6 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/application/exception/CartItemException.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.application.exception; + +public class CartItemException { +} diff --git a/src/main/java/com/cMall/feedShop/cart/domain/Cart.java b/src/main/java/com/cMall/feedShop/cart/domain/Cart.java new file mode 100644 index 000000000..a18e3e7e9 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/domain/Cart.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.domain; + +public class Cart { +} diff --git a/src/main/java/com/cMall/feedShop/cart/domain/CartItem.java b/src/main/java/com/cMall/feedShop/cart/domain/CartItem.java new file mode 100644 index 000000000..422a9f54e --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/domain/CartItem.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.domain; + +public class CartItem { +} diff --git a/src/main/java/com/cMall/feedShop/cart/domain/repository/CartRepository.java b/src/main/java/com/cMall/feedShop/cart/domain/repository/CartRepository.java new file mode 100644 index 000000000..e2170bd84 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/domain/repository/CartRepository.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.domain.repository; + +public interface CartRepository { +} diff --git a/src/main/java/com/cMall/feedShop/cart/infrastructure/CartRepositoryImpl.java b/src/main/java/com/cMall/feedShop/cart/infrastructure/CartRepositoryImpl.java new file mode 100644 index 000000000..e98004626 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/infrastructure/CartRepositoryImpl.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.infrastructure; + +public class CartRepositoryImpl { +} diff --git a/src/main/java/com/cMall/feedShop/cart/presentation/CartApi.java b/src/main/java/com/cMall/feedShop/cart/presentation/CartApi.java new file mode 100644 index 000000000..4688fd435 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/presentation/CartApi.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.presentation; + +public interface CartApi { +} diff --git a/src/main/java/com/cMall/feedShop/cart/presentation/CartController.java b/src/main/java/com/cMall/feedShop/cart/presentation/CartController.java new file mode 100644 index 000000000..58a8228cc --- /dev/null +++ b/src/main/java/com/cMall/feedShop/cart/presentation/CartController.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.cart.presentation; + +public class CartController implements CartApi{ +} diff --git a/src/main/java/com/cMall/feedShop/chat/application/ChatService.java b/src/main/java/com/cMall/feedShop/chat/application/ChatService.java new file mode 100644 index 000000000..d77a3aa5a --- /dev/null +++ b/src/main/java/com/cMall/feedShop/chat/application/ChatService.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.chat.application; + +public class ChatService { +} diff --git a/src/main/java/com/cMall/feedShop/chat/application/exception/ChatException.java b/src/main/java/com/cMall/feedShop/chat/application/exception/ChatException.java new file mode 100644 index 000000000..af643008a --- /dev/null +++ b/src/main/java/com/cMall/feedShop/chat/application/exception/ChatException.java @@ -0,0 +1,7 @@ +package com.cMall.feedShop.chat.application.exception; + +public class ChatException extends RuntimeException { + public ChatException(String message) { + super(message); + } +} diff --git a/src/main/java/com/cMall/feedShop/chat/domain/Chat.java b/src/main/java/com/cMall/feedShop/chat/domain/Chat.java new file mode 100644 index 000000000..e7eca665c --- /dev/null +++ b/src/main/java/com/cMall/feedShop/chat/domain/Chat.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.chat.domain; + +public class Chat { +} diff --git a/src/main/java/com/cMall/feedShop/chat/domain/repository/ChatRepository.java b/src/main/java/com/cMall/feedShop/chat/domain/repository/ChatRepository.java new file mode 100644 index 000000000..e72b16ecb --- /dev/null +++ b/src/main/java/com/cMall/feedShop/chat/domain/repository/ChatRepository.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.chat.domain.repository; + +public interface ChatRepository { +} diff --git a/src/main/java/com/cMall/feedShop/chat/domain/repository/CustomReviewRepository.java b/src/main/java/com/cMall/feedShop/chat/domain/repository/CustomReviewRepository.java new file mode 100644 index 000000000..800b2b187 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/chat/domain/repository/CustomReviewRepository.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.chat.domain.repository; + +public interface CustomReviewRepository { +} diff --git a/src/main/java/com/cMall/feedShop/chat/infrastructure/ChatRepositoryImpl.java b/src/main/java/com/cMall/feedShop/chat/infrastructure/ChatRepositoryImpl.java new file mode 100644 index 000000000..6f53e5b73 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/chat/infrastructure/ChatRepositoryImpl.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.chat.infrastructure; + +public class ChatRepositoryImpl { +} diff --git a/src/main/java/com/cMall/feedShop/chat/presentation/ChatController.java b/src/main/java/com/cMall/feedShop/chat/presentation/ChatController.java new file mode 100644 index 000000000..87795799d --- /dev/null +++ b/src/main/java/com/cMall/feedShop/chat/presentation/ChatController.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.chat.presentation; + +public class ChatController { +} diff --git a/src/main/java/com/cMall/feedShop/common/aop/ApiResponseFormat.java b/src/main/java/com/cMall/feedShop/common/aop/ApiResponseFormat.java new file mode 100644 index 000000000..00c8c074f --- /dev/null +++ b/src/main/java/com/cMall/feedShop/common/aop/ApiResponseFormat.java @@ -0,0 +1,20 @@ +package com.cMall.feedShop.common.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiResponseFormat { + /** + * 성공 시 반환할 메시지 + */ + String message() default "요청이 성공적으로 처리되었습니다."; + + /** + * HTTP 상태 코드 (기본값: 200) + */ + int status() default 200; +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/common/aop/LoggingAspect.java b/src/main/java/com/cMall/feedShop/common/aop/LoggingAspect.java new file mode 100644 index 000000000..f5344b271 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/common/aop/LoggingAspect.java @@ -0,0 +1,214 @@ +package com.cMall.feedShop.common.aop; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import java.util.Arrays; +import java.util.UUID; + +/** + * 쇼핑몰 프로젝트용 통합 로깅 AOP + * - 성능 모니터링 (느린 메서드 감지) + * - 에러 추적 (예외 발생 지점) + * - API 호출 추적 (요청별 추적 ID) + * - 비즈니스 로직 흐름 추적 + */ +@Aspect +@Component +public class LoggingAspect { + + private static final Logger log = LoggerFactory.getLogger(LoggingAspect.class); + private final ObjectMapper objectMapper = new ObjectMapper(); + private static final long SLOW_METHOD_THRESHOLD = 1000; // 1초 이상이면 느린 메서드로 간주 + + // =========================== Pointcut 정의 =========================== + + // Controller 레이어 + @Pointcut("execution(* com.cMall.feedShop..*controller.*.*(..)) || execution(* com.cMall.feedShop..presentation.*.*(..))") + private void controllerMethods() {} + + // Service 레이어 (핵심 비즈니스 로직) + @Pointcut("execution(* com.cMall.feedShop..*service.*.*(..)) || execution(* com.cMall.feedShop..application.service.*.*(..))") + private void serviceMethods() {} + + // Repository 레이어 (데이터베이스 접근) + @Pointcut("execution(* com.cMall.feedShop..repository.*.*(..))") + private void repositoryMethods() {} + + // =========================== Controller 로깅 =========================== + + @Around("controllerMethods()") + public Object logController(ProceedingJoinPoint joinPoint) throws Throwable { + // API 요청별 고유 추적 ID 생성 + String traceId = generateTraceId(); + MDC.put("traceId", traceId); + + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + Object[] args = joinPoint.getArgs(); + + long start = System.currentTimeMillis(); + + log.info("🌐 [API-START] {}.{}() | TraceID: {}", + className, methodName, traceId); + + if (args.length > 0) { + log.info("📥 [REQUEST] Args: {}", formatArgs(args)); + } + + try { + Object result = joinPoint.proceed(); + long duration = System.currentTimeMillis() - start; + + log.info("📤 [RESPONSE] Return: {} | Duration: {}ms", + formatResult(result), duration); + log.info("✅ [API-END] {}.{}() SUCCESS | TraceID: {}", + className, methodName, traceId); + + return result; + } catch (Throwable throwable) { + long duration = System.currentTimeMillis() - start; + log.error("❌ [API-ERROR] {}.{}() | Duration: {}ms | Error: {} | TraceID: {}", + className, methodName, duration, throwable.getMessage(), traceId, throwable); + throw throwable; + } finally { + MDC.clear(); + } + } + + // =========================== Service 로깅 =========================== + + @Around("serviceMethods()") + public Object logService(ProceedingJoinPoint joinPoint) throws Throwable { + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + Object[] args = joinPoint.getArgs(); + + long start = System.currentTimeMillis(); + String traceId = MDC.get("traceId"); + + log.info("🔧 [SERVICE-START] {}.{}() | TraceID: {}", className, methodName, traceId); + + try { + Object result = joinPoint.proceed(); + long duration = System.currentTimeMillis() - start; + + // 느린 메서드 감지 + if (duration > SLOW_METHOD_THRESHOLD) { + log.warn("🐌 [SLOW-METHOD] {}.{}() took {}ms (>{} ms threshold)", + className, methodName, duration, SLOW_METHOD_THRESHOLD); + } + + log.info("✅ [SERVICE-END] {}.{}() | Duration: {}ms | TraceID: {}", + className, methodName, duration, traceId); + + return result; + } catch (Throwable throwable) { + long duration = System.currentTimeMillis() - start; + log.error("❌ [SERVICE-ERROR] {}.{}() | Duration: {}ms | Error: {} | TraceID: {}", + className, methodName, duration, throwable.getMessage(), traceId, throwable); + throw throwable; + } + } + + // =========================== Repository 로깅 =========================== + + @Around("repositoryMethods()") + public Object logRepository(ProceedingJoinPoint joinPoint) throws Throwable { + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + + long start = System.currentTimeMillis(); + String traceId = MDC.get("traceId"); + + log.debug("💾 [DB-START] {}.{}() | TraceID: {}", className, methodName, traceId); + + try { + Object result = joinPoint.proceed(); + long duration = System.currentTimeMillis() - start; + + // DB 쿼리가 느린 경우 경고 + if (duration > 500) { // 0.5초 이상 + log.warn("🐌 [SLOW-QUERY] {}.{}() took {}ms | TraceID: {}", + className, methodName, duration, traceId); + } else { + log.debug("✅ [DB-END] {}.{}() | Duration: {}ms | TraceID: {}", + className, methodName, duration, traceId); + } + + return result; + } catch (Throwable throwable) { + long duration = System.currentTimeMillis() - start; + log.error("❌ [DB-ERROR] {}.{}() | Duration: {}ms | Error: {} | TraceID: {}", + className, methodName, duration, throwable.getMessage(), traceId, throwable); + throw throwable; + } + } + + // =========================== 유틸리티 메서드 =========================== + + /** + * 요청별 고유 추적 ID 생성 + */ + private String generateTraceId() { + return UUID.randomUUID().toString().substring(0, 8); + } + + /** + * 메서드 파라미터 포맷팅 (민감한 정보 마스킹) + */ + private String formatArgs(Object[] args) { + if (args == null || args.length == 0) return "[]"; + + Object[] maskedArgs = Arrays.stream(args) + .map(this::maskSensitiveData) + .toArray(); + + return Arrays.toString(maskedArgs); + } + + /** + * 반환값 포맷팅 (큰 객체는 요약) + */ + private String formatResult(Object result) { + if (result == null) return "null"; + + String resultStr = result.toString(); + // 너무 긴 결과는 요약 + if (resultStr.length() > 200) { + return result.getClass().getSimpleName() + "[" + resultStr.substring(0, 100) + "...]"; + } + + return maskSensitiveData(result).toString(); + } + + /** + * 민감한 정보 마스킹 (비밀번호, 카드번호 등) + */ + private Object maskSensitiveData(Object obj) { + if (obj == null) return null; + + String str = obj.toString(); + + // 비밀번호 필드 마스킹 + if (str.contains("password") || str.contains("pwd")) { + return str.replaceAll("(password|pwd)=[^,\\s}]+", "$1=***"); + } + + // 카드번호 마스킹 (16자리 숫자) + str = str.replaceAll("\\b\\d{4}[-\\s]?\\d{4}[-\\s]?\\d{4}[-\\s]?\\d{4}\\b", + "****-****-****-****"); + + // 이메일 부분 마스킹 + str = str.replaceAll("([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})", + "$1***@$2"); + + return str; + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/common/aop/PasswordEncryptionAspect.java b/src/main/java/com/cMall/feedShop/common/aop/PasswordEncryptionAspect.java new file mode 100644 index 000000000..7ea512528 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/common/aop/PasswordEncryptionAspect.java @@ -0,0 +1,64 @@ +package com.cMall.feedShop.common.aop; + +import com.cMall.feedShop.annotation.CustomEncryption; +import com.cMall.feedShop.user.domain.service.PasswordEncryptionService; +import lombok.AllArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Modifier; +import java.util.Arrays; + +@Aspect +@Component +@AllArgsConstructor +public class PasswordEncryptionAspect { + + private final PasswordEncryptionService passwordEncryptionService; + + /** + * com.cMall.feedShop.user.presentation 패키지 (및 하위) 내의 + * 모든 클래스의 모든 메서드 실행 전후에 이 Aspect를 적용합니다. + * 컨트롤러에서 DTO를 받을 때 비밀번호를 암호화하는 용도입니다. + */ + @Around("execution(* com.cMall.feedShop.user.presentation..*.*(..))") + public Object passwordEncryptionAspect(ProceedingJoinPoint pjp) throws Throwable { + Arrays.stream(pjp.getArgs()) + .forEach(this::fieldEncryption); + + return pjp.proceed(); + } + + public void fieldEncryption(Object object) { + if (ObjectUtils.isEmpty(object)) { + return; + } + + FieldUtils.getAllFieldsList(object.getClass()) + .stream() + .filter(field -> !(Modifier.isFinal(field.getModifiers()) && Modifier.isStatic(field.getModifiers()))) + .forEach(field -> { + try { + boolean encryptionTarget = field.isAnnotationPresent(CustomEncryption.class); + if (!encryptionTarget) { + return; + } + + Object fieldValue = FieldUtils.readField(field, object, true); + if (!(fieldValue instanceof String)) { + return; + } + + String rawString = (String) fieldValue; + String encryptedString = passwordEncryptionService.encrypt(rawString); + FieldUtils.writeField(field, object, encryptedString, true); + } catch (Exception e) { + throw new RuntimeException("Error during field encryption: " + field.getName(), e); + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/common/aop/ResponseFormatAspect.java b/src/main/java/com/cMall/feedShop/common/aop/ResponseFormatAspect.java new file mode 100644 index 000000000..49309d6ff --- /dev/null +++ b/src/main/java/com/cMall/feedShop/common/aop/ResponseFormatAspect.java @@ -0,0 +1,37 @@ +package com.cMall.feedShop.common.aop; + +import com.cMall.feedShop.common.dto.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@Slf4j +public class ResponseFormatAspect { + + @Around("@annotation(apiResponseFormat)") + public Object formatResponse(ProceedingJoinPoint joinPoint, ApiResponseFormat apiResponseFormat) throws Throwable { + try { + Object result = joinPoint.proceed(); + + // 이미 ApiResponse 타입이면 그대로 반환 + if (result instanceof ApiResponse) { + return result; + } + + // 성공 응답으로 래핑 + String message = apiResponseFormat.message().isEmpty() ? + "요청이 성공했습니다." : apiResponseFormat.message(); + + return ApiResponse.success(message, result); + + } catch (Exception e) { + log.error("API 실행 중 오류 발생 - Method: {}, Error: {}", + joinPoint.getSignature().getName(), e.getMessage()); + throw e; // 예외는 GlobalExceptionHandler에서 처리 + } + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/common/dto/ApiResponse.java b/src/main/java/com/cMall/feedShop/common/dto/ApiResponse.java new file mode 100644 index 000000000..a0df70762 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/common/dto/ApiResponse.java @@ -0,0 +1,44 @@ +package com.cMall.feedShop.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ApiResponse { + private boolean success; + private String message; + private T data; + @Builder.Default + private String timestamp = LocalDateTime.now().toString(); + + public static ApiResponse success(T data) { + return ApiResponse.builder() + .success(true) + .message("요청이 성공했습니다.") + .data(data) + .build(); + } + + public static ApiResponse success(String message, T data) { + return ApiResponse.builder() + .success(true) + .message(message) + .data(data) + .build(); + } + + public static ApiResponse error(String message) { + return ApiResponse.builder() + .success(false) + .message(message) + .data(null) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/common/exception/BusinessException.java b/src/main/java/com/cMall/feedShop/common/exception/BusinessException.java new file mode 100644 index 000000000..c74b6b2d3 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/common/exception/BusinessException.java @@ -0,0 +1,23 @@ +package com.cMall.feedShop.common.exception; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + // errorCode 문자열 반환 (리뷰 서비스 호환용) + public String getErrorCodeString() { + return errorCode != null ? errorCode.getCode() : "UNKNOWN_ERROR"; + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/common/exception/ErrorCode.java b/src/main/java/com/cMall/feedShop/common/exception/ErrorCode.java new file mode 100644 index 000000000..939847335 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/common/exception/ErrorCode.java @@ -0,0 +1,43 @@ +package com.cMall.feedShop.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + // 공통 + INVALID_INPUT_VALUE(400, "C001", "잘못된 입력값입니다."), + METHOD_NOT_ALLOWED(405, "C002", "지원하지 않는 HTTP 메서드입니다."), + INTERNAL_SERVER_ERROR(500, "C003", "서버 오류가 발생했습니다."), + + // 인증/인가 + UNAUTHORIZED(401, "A001", "인증이 필요합니다."), + FORBIDDEN(403, "A002", "권한이 없습니다."), + + // 사용자 + USER_NOT_FOUND(404, "U001", "사용자를 찾을 수 없습니다."), + DUPLICATE_EMAIL(409, "U002", "이미 존재하는 이메일입니다."), + + // 상품 + PRODUCT_NOT_FOUND(404, "P001", "상품을 찾을 수 없습니다."), + OUT_OF_STOCK(409, "P002", "재고가 부족합니다."), + + // 주문 + ORDER_NOT_FOUND(404, "O001", "주문을 찾을 수 없습니다."), + INVALID_ORDER_STATUS(400, "O002", "잘못된 주문 상태입니다."), + + // 리뷰 관련 + REVIEW_NOT_FOUND(404, "R001", "존재하지 않는 리뷰입니다."), + REVIEW_ALREADY_EXISTS(409, "R002", "이미 해당 상품에 대한 리뷰가 존재합니다."), + REVIEW_ACCESS_DENIED(403, "R003", "리뷰에 대한 권한이 없습니다."), + INVALID_RATING(400, "R004", "평점은 1-5 사이의 값이어야 합니다."), + REVIEW_CONTENT_TOO_LONG(400, "R005", "리뷰 내용은 1000자를 초과할 수 없습니다."), + REVIEW_TITLE_REQUIRED(400, "R006", "리뷰 제목은 필수입니다."), + REVIEW_TITLE_TOO_LONG(400, "R007", "리뷰 제목은 100자 이하여야 합니다."); + + + private final int status; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/common/exception/ErrorResponse.java b/src/main/java/com/cMall/feedShop/common/exception/ErrorResponse.java new file mode 100644 index 000000000..1fed9ce29 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/common/exception/ErrorResponse.java @@ -0,0 +1,63 @@ +package com.cMall.feedShop.common.exception; + +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +public class ErrorResponse { + private final boolean success = false; + private final String code; + private final String message; + private final String timestamp; + private final List fieldErrors; + + // 생성자 + private ErrorResponse(String code, String message, String timestamp, List fieldErrors) { + this.code = code; + this.message = message; + this.timestamp = timestamp; + this.fieldErrors = fieldErrors; + } + + // 팩토리 메서드 (필드 에러 없음) + public static ErrorResponse of(ErrorCode errorCode) { + return new ErrorResponse( + errorCode.getCode(), + errorCode.getMessage(), + LocalDateTime.now().toString(), + new ArrayList<>() + ); + } + + // 팩토리 메서드 (필드 에러 포함) + public static ErrorResponse of(ErrorCode errorCode, List fieldErrors) { + return new ErrorResponse( + errorCode.getCode(), + errorCode.getMessage(), + LocalDateTime.now().toString(), + fieldErrors + ); + } + + // 내부 클래스 + @Getter + public static class FieldError { + private final String field; + private final String value; + private final String reason; + + private FieldError(String field, String value, String reason) { + this.field = field; + this.value = value; + this.reason = reason; + } + + // 팩토리 메서드 + public static FieldError of(String field, String value, String reason) { + return new FieldError(field, value, reason); + } + } +} diff --git a/src/main/java/com/cMall/feedShop/common/exception/GlobalExceptionHandler.java b/src/main/java/com/cMall/feedShop/common/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..1ea9a88dd --- /dev/null +++ b/src/main/java/com/cMall/feedShop/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,100 @@ +package com.cMall.feedShop.common.exception; + +import com.cMall.feedShop.common.dto.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.List; +import java.util.stream.Collectors; + +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + // 비즈니스 예외 - ApiResponse 형태로 반환 + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusinessException(BusinessException e) { + log.error("BusinessException: {}", e.getMessage()); + + ApiResponse response = ApiResponse.builder() + .success(false) + .message(e.getErrorCode().getMessage()) + .data(null) + .build(); + + return ResponseEntity.status(e.getErrorCode().getStatus()).body(response); + } + + // 유효성 검사 예외 - 필드 에러 정보 포함 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleMethodArgumentNotValidException( + MethodArgumentNotValidException e) { + log.error("Validation error: {}", e.getMessage()); + + List fieldErrors = e.getBindingResult() + .getFieldErrors() + .stream() + .map(error -> ErrorResponse.FieldError.of( + error.getField(), + error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(), + error.getDefaultMessage() + )) + .collect(Collectors.toList()); + + ApiResponse> response = ApiResponse.>builder() + .success(false) + .message("입력값이 올바르지 않습니다.") + .data(fieldErrors) + .build(); + + return ResponseEntity.badRequest().body(response); + } + + // 인증 예외 + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity> handleAuthenticationException(AuthenticationException e) { + log.error("Authentication error: {}", e.getMessage()); + + ApiResponse response = ApiResponse.builder() + .success(false) + .message("인증이 필요합니다.") + .data(null) + .build(); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); + } + + // 권한 예외 + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDeniedException(AccessDeniedException e) { + log.error("Access denied: {}", e.getMessage()); + + ApiResponse response = ApiResponse.builder() + .success(false) + .message("접근 권한이 없습니다.") + .data(null) + .build(); + + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } + + // 일반 예외 + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + log.error("Unexpected error: {}", e.getMessage(), e); + + ApiResponse response = ApiResponse.builder() + .success(false) + .message("서버 오류가 발생했습니다.") + .data(null) + .build(); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/config/SecurityConfig.java b/src/main/java/com/cMall/feedShop/config/SecurityConfig.java new file mode 100644 index 000000000..de91c7d41 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/config/SecurityConfig.java @@ -0,0 +1,86 @@ +package com.cMall.feedShop.config; + +import com.cMall.feedShop.user.infrastructure.service.CustomUserDetailsService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; // <-- 추가 +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.http.HttpStatus; // <-- 추가 + +import java.util.List; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final CustomUserDetailsService customUserDetailsService; + + public SecurityConfig(CustomUserDetailsService customUserDetailsService) { + this.customUserDetailsService = customUserDetailsService; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager( + PasswordEncoder passwordEncoder) { + DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); + authenticationProvider.setUserDetailsService(customUserDetailsService); + authenticationProvider.setPasswordEncoder(passwordEncoder); + return new ProviderManager(authenticationProvider); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { + httpSecurity + .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // 예외 처리 (인증되지 않은 사용자가 보호된 리소스에 접근 시 처리) + .exceptionHandling(exceptionHandling -> exceptionHandling + // 인증되지 않은 사용자가 접근 시 HTTP 401 Unauthorized 응답을 반환하도록 설정 + // React는 이 401 응답을 받아서 자체적으로 로그인 페이지로 리다이렉트하거나 메시지를 표시합니다. + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + ) + + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/login", "/api/auth/signup", "/public/**", + "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll() + .anyRequest().authenticated() + ) + // 폼 로그인 및 HTTP Basic 인증은 사용하지 않음 + .formLogin(formLogin -> formLogin.disable()) + .httpBasic(httpBasic -> httpBasic.disable()); + + return httpSecurity.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:3000")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/src/main/java/com/cMall/feedShop/config/WebConfig.java b/src/main/java/com/cMall/feedShop/config/WebConfig.java new file mode 100644 index 000000000..939790836 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/config/WebConfig.java @@ -0,0 +1,17 @@ +package com.cMall.feedShop.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") // 또는 "/**" + .allowedOrigins("http://localhost:3000") + .allowedMethods("*") + .allowedHeaders("*") + .allowCredentials(true); + } +} diff --git a/src/main/java/com/cMall/feedShop/order/application/OrderService.java b/src/main/java/com/cMall/feedShop/order/application/OrderService.java new file mode 100644 index 000000000..c0477b3fa --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/application/OrderService.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.application; + +public class OrderService { +} diff --git a/src/main/java/com/cMall/feedShop/order/application/dto/OrderItemInfo.java b/src/main/java/com/cMall/feedShop/order/application/dto/OrderItemInfo.java new file mode 100644 index 000000000..0dd2f0ef3 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/application/dto/OrderItemInfo.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.application.dto; + +public class OrderItemInfo { +} diff --git a/src/main/java/com/cMall/feedShop/order/application/dto/request/OrderCreateRequest.java b/src/main/java/com/cMall/feedShop/order/application/dto/request/OrderCreateRequest.java new file mode 100644 index 000000000..6c21481b9 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/application/dto/request/OrderCreateRequest.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.application.dto.request; + +public class OrderCreateRequest { +} diff --git a/src/main/java/com/cMall/feedShop/order/application/dto/request/OrderRequest.java b/src/main/java/com/cMall/feedShop/order/application/dto/request/OrderRequest.java new file mode 100644 index 000000000..b49fe5c22 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/application/dto/request/OrderRequest.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.application.dto.request; + +public class OrderRequest { +} diff --git a/src/main/java/com/cMall/feedShop/order/application/dto/response/OrderCancelResponse.java b/src/main/java/com/cMall/feedShop/order/application/dto/response/OrderCancelResponse.java new file mode 100644 index 000000000..d8167b6b2 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/application/dto/response/OrderCancelResponse.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.application.dto.response; + +public class OrderCancelResponse { +} diff --git a/src/main/java/com/cMall/feedShop/order/application/dto/response/OrderCreateResponse.java b/src/main/java/com/cMall/feedShop/order/application/dto/response/OrderCreateResponse.java new file mode 100644 index 000000000..71173299d --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/application/dto/response/OrderCreateResponse.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.application.dto.response; + +public class OrderCreateResponse { +} diff --git a/src/main/java/com/cMall/feedShop/order/application/exception/OrderException.java b/src/main/java/com/cMall/feedShop/order/application/exception/OrderException.java new file mode 100644 index 000000000..077b8fb51 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/application/exception/OrderException.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.application.exception; + +public class OrderException { +} diff --git a/src/main/java/com/cMall/feedShop/order/domain/Order.java b/src/main/java/com/cMall/feedShop/order/domain/Order.java new file mode 100644 index 000000000..74669a6d3 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/domain/Order.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.domain; + +public class Order { +} diff --git a/src/main/java/com/cMall/feedShop/order/domain/OrderItem.java b/src/main/java/com/cMall/feedShop/order/domain/OrderItem.java new file mode 100644 index 000000000..60738970b --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/domain/OrderItem.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.domain; + +public class OrderItem { +} diff --git a/src/main/java/com/cMall/feedShop/order/domain/OrderStatus.java b/src/main/java/com/cMall/feedShop/order/domain/OrderStatus.java new file mode 100644 index 000000000..377e4fd42 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/domain/OrderStatus.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.domain; + +public enum OrderStatus { +} diff --git a/src/main/java/com/cMall/feedShop/order/domain/repository/OrderRepository.java b/src/main/java/com/cMall/feedShop/order/domain/repository/OrderRepository.java new file mode 100644 index 000000000..0609ffb51 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/domain/repository/OrderRepository.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.domain.repository; + +public interface OrderRepository { +} diff --git a/src/main/java/com/cMall/feedShop/order/infrastructure/OrderRepositoryImpl.java b/src/main/java/com/cMall/feedShop/order/infrastructure/OrderRepositoryImpl.java new file mode 100644 index 000000000..a8b6001d6 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/infrastructure/OrderRepositoryImpl.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.infrastructure; + +public class OrderRepositoryImpl { +} diff --git a/src/main/java/com/cMall/feedShop/order/presentation/OrderApi.java b/src/main/java/com/cMall/feedShop/order/presentation/OrderApi.java new file mode 100644 index 000000000..55eaf089e --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/presentation/OrderApi.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.presentation; + +public interface OrderApi { +} diff --git a/src/main/java/com/cMall/feedShop/order/presentation/OrderController.java b/src/main/java/com/cMall/feedShop/order/presentation/OrderController.java new file mode 100644 index 000000000..3e3803afb --- /dev/null +++ b/src/main/java/com/cMall/feedShop/order/presentation/OrderController.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.order.presentation; + +public class OrderController implements OrderApi{ +} diff --git a/src/main/java/com/cMall/feedShop/review/application/ReviewImageService.java b/src/main/java/com/cMall/feedShop/review/application/ReviewImageService.java new file mode 100644 index 000000000..9119fa65f --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/ReviewImageService.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application; + +public class ReviewImageService { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/ReviewReportService.java b/src/main/java/com/cMall/feedShop/review/application/ReviewReportService.java new file mode 100644 index 000000000..75c530c4a --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/ReviewReportService.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application; + +public class ReviewReportService { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/ReviewService.java b/src/main/java/com/cMall/feedShop/review/application/ReviewService.java new file mode 100644 index 000000000..66d46f12a --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/ReviewService.java @@ -0,0 +1,246 @@ +package com.cMall.feedShop.review.application; + +import com.cMall.feedShop.review.application.dto.request.ReviewCreateRequest; +import com.cMall.feedShop.review.application.dto.request.ReviewUpdateRequest; +import com.cMall.feedShop.review.application.dto.response.ReviewCreateResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewDetailResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewSummaryResponse; +import com.cMall.feedShop.review.application.dto.response.ProductReviewSummaryResponse; +import com.cMall.feedShop.review.domain.entity.*; +import com.cMall.feedShop.review.domain.repository.ReviewRepository; +import com.cMall.feedShop.review.domain.repository.ReviewImageRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.ArrayList; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final ReviewImageRepository reviewImageRepository; + + @Transactional + public ReviewCreateResponse createReview(ReviewCreateRequest request) { + // 1. 이미 리뷰를 작성했는지 확인 + if (reviewRepository.existsByUserIdAndProductIdAndStatusActive(request.getUserId(), request.getProductId())) { + throw new IllegalArgumentException("이미 해당 상품에 대한 리뷰를 작성하셨습니다."); + } + + // 2. 리뷰 엔티티 생성 (모두 enum 사용) + Review review = Review.builder() + .userId(request.getUserId()) + .productId(request.getProductId()) + .reviewTitle(request.getReviewTitle()) + .rating(request.getRating()) + .content(request.getContent()) + .sizeFit(request.getSizeFit()) // enum 그대로 + .cushioning(request.getCushioning()) // enum 그대로 + .stability(request.getStability()) // enum 그대로 + .status(ReviewStatus.ACTIVE) + .build(); + + // 3. 리뷰 저장 + Review savedReview = reviewRepository.save(review); + + // 4. 이미지가 있다면 저장 + List imageUrls = request.getImageUrls(); + if (imageUrls != null && !imageUrls.isEmpty()) { + for (int i = 0; i < imageUrls.size(); i++) { + ReviewImage reviewImage = ReviewImage.builder() + .reviewId(savedReview.getReviewId()) + .imageUrl(imageUrls.get(i)) + .imageOrder(i + 1) + .build(); + reviewImageRepository.save(reviewImage); + } + } + + // 5. 응답 객체 생성 (모두 enum 그대로) + return ReviewCreateResponse.builder() + .reviewId(savedReview.getReviewId()) + .productId(savedReview.getProductId()) + .userId(savedReview.getUserId()) + .reviewTitle(savedReview.getReviewTitle()) + .rating(savedReview.getRating()) + .content(savedReview.getContent()) + .sizeFit(savedReview.getSizeFit()) // enum 그대로 + .cushioning(savedReview.getCushioning()) // enum 그대로 + .stability(savedReview.getStability()) // enum 그대로 + .imageUrls(imageUrls) + .createdAt(savedReview.getCreatedAt()) + .build(); + } + + public ProductReviewSummaryResponse getProductReviews(Long productId, Pageable pageable) { + // 1. 상품의 리뷰 목록 조회 + Page reviewPage = reviewRepository.findByProductIdAndStatus(productId, ReviewStatus.ACTIVE, pageable); + + // 2. 평균 평점 조회 + Double averageRating = reviewRepository.findAverageRatingByProductId(productId); + + // 3. 총 리뷰 수 조회 + Long totalReviewCount = reviewRepository.countByProductIdAndStatus(productId, ReviewStatus.ACTIVE); + + // 4. 최근 리뷰들을 기존 ReviewSummaryResponse로 변환 + List recentReviews = reviewPage.getContent().stream() + .map(this::convertToSummaryResponse) + .collect(Collectors.toList()); + + // 5. 평점 분포 계산 (간단한 버전) + ProductReviewSummaryResponse.RatingDistribution ratingDistribution = ProductReviewSummaryResponse.RatingDistribution.builder() + .fiveStar(0L) + .fourStar(0L) + .threeStar(0L) + .twoStar(0L) + .oneStar(0L) + .build(); + + return ProductReviewSummaryResponse.builder() + .productId(productId) + .totalReviews(totalReviewCount) + .averageRating(averageRating != null ? averageRating : 0.0) + .ratingDistribution(ratingDistribution) + .mostCommonSizeFit("보통") // 임시값 + .recentReviews(recentReviews) + .build(); + } + + public ReviewDetailResponse getReviewDetail(Long reviewId) { + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new IllegalArgumentException("리뷰를 찾을 수 없습니다.")); + + return convertToDetailResponse(review); + } + + public Page getUserReviews(Long userId, Pageable pageable) { + Page reviewPage = reviewRepository.findByUserIdAndStatus(userId, ReviewStatus.ACTIVE, pageable); + + return reviewPage.map(this::convertToDetailResponse); + } + + @Transactional + public void updateReview(Long reviewId, ReviewUpdateRequest request) { + // 1. 리뷰 조회 + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new IllegalArgumentException("리뷰를 찾을 수 없습니다.")); + + // 2. 리뷰 업데이트 + if (request.getReviewTitle() != null) { + review.updateTitle(request.getReviewTitle()); + } + + if (request.getContent() != null) { + review.updateContent(request.getContent()); + } + + if (request.getRating() != null) { + review.updateRating(request.getRating()); + } + + if (request.getSizeFit() != null) { + review.updateSizeFit(request.getSizeFit()); + } + + if (request.getCushioning() != null) { + review.updateCushioning(request.getCushioning()); + } + + if (request.getStability() != null) { + review.updateStability(request.getStability()); + } + + // 3. 저장 + reviewRepository.save(review); + } + + private ReviewDetailResponse convertToDetailResponse(Review review) { + // 리뷰의 이미지들 조회 + List images = reviewImageRepository.findByReviewIdOrderByImageOrder(review.getReviewId()); + List imageUrls = images.stream() + .map(ReviewImage::getImageUrl) + .collect(Collectors.toList()); + + return ReviewDetailResponse.builder() + .reviewId(review.getReviewId()) + .productId(review.getProductId()) + .userId(review.getUserId()) + .userName("사용자" + review.getUserId()) // 실제로는 User 엔티티에서 조회 + .reviewTitle(review.getReviewTitle()) + .rating(review.getRating()) + .content(review.getContent()) + .sizeFit(review.getSizeFit()) // enum 그대로 + .cushioning(review.getCushioning()) // enum 그대로 + .stability(review.getStability()) // enum 그대로 + .imageUrls(imageUrls) + .createdAt(review.getCreatedAt()) + .updatedAt(review.getUpdatedAt()) + .build(); + } + + private ReviewSummaryResponse convertToSummaryResponse(Review review) { + return ReviewSummaryResponse.builder() + .reviewId(review.getReviewId()) + .userId(review.getUserId()) + .productId(review.getProductId()) + .reviewTitle(review.getReviewTitle()) + .content(review.getContent()) + .rating(review.getRating()) + .sizeFit(review.getSizeFit()) // enum 그대로 + .cushioning(review.getCushioning()) // enum 그대로 + .stability(review.getStability()) // enum 그대로 + .createdAt(review.getCreatedAt()) + .images(new ArrayList<>()) // 빈 리스트로 초기화 + .build(); + } + + /** + * 특정 상품의 사이즈 핏별 리뷰 조회 + */ + public List getReviewsBySizeFit(Long productId, SizeFit sizeFit) { + List reviews = reviewRepository.findByProductIdAndSizeFitAndStatus( + productId, sizeFit, ReviewStatus.ACTIVE + ); + + return reviews.stream() + .map(this::convertToDetailResponse) + .collect(Collectors.toList()); + } + + /** + * 특정 상품의 쿠셔닝별 리뷰 조회 + */ + public List getReviewsByCushioning(Long productId, Cushion cushioning) { + List reviews = reviewRepository.findByProductIdAndCushioningAndStatus( + productId, cushioning, ReviewStatus.ACTIVE + ); + + return reviews.stream() + .map(this::convertToDetailResponse) + .collect(Collectors.toList()); + } + + /** + * 특정 상품의 안정성별 리뷰 조회 + */ + public List getReviewsByStability(Long productId, Stability stability) { + List reviews = reviewRepository.findByProductIdAndStabilityAndStatus( + productId, stability, ReviewStatus.ACTIVE + ); + + return reviews.stream() + .map(review -> convertToDetailResponse(review)) + .collect(Collectors.toList()); + + } + +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/application/ReviewStatisticsService.java b/src/main/java/com/cMall/feedShop/review/application/ReviewStatisticsService.java new file mode 100644 index 000000000..6a64b27f4 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/ReviewStatisticsService.java @@ -0,0 +1,122 @@ +package com.cMall.feedShop.review.application; + +import com.cMall.feedShop.review.domain.repository.ReviewRepository; +import com.cMall.feedShop.review.domain.entity.ReviewStatus; +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Stability; +import com.cMall.feedShop.review.application.dto.response.ReviewStatisticsResponse; +import com.cMall.feedShop.review.application.dto.response.ProductReviewSummaryResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.ArrayList; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class ReviewStatisticsService { + + private final ReviewRepository reviewRepository; + + /** + * 상품별 리뷰 통계 조회 + */ + public ReviewStatisticsResponse getProductStatistics(Long productId) { + // 평균 평점 조회 + Double averageRating = reviewRepository.findAverageRatingByProductId(productId); + if (averageRating == null) { + throw new IllegalArgumentException("해당 상품의 리뷰가 없습니다."); + } + + // 총 리뷰 수 조회 + Long totalReviews = reviewRepository.countByProductIdAndStatus(productId, ReviewStatus.ACTIVE); + + // 평점 분포 조회 + Map ratingDistribution = Map.of( + 5, reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 5), + 4, reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 4), + 3, reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 3), + 2, reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 2), + 1, reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 1) + ); + + // 사이즈 핏 분포 (예시) + Map sizeFitDistribution = Map.of( + "PERFECT", 30L, + "BIG", 15L, + "SMALL", 5L + ); + + // 안정성 분포 (예시) + Map stabilityDistribution = Map.of( + "VERY_STABLE", 25L, + "STABLE", 20L, + "NORMAL", 5L + ); + + return ReviewStatisticsResponse.builder() + .productId(productId) + .averageRating(averageRating) + .totalReviews(totalReviews) + .ratingDistribution(ratingDistribution) + .sizeFitDistribution(sizeFitDistribution) + .stabilityDistribution(stabilityDistribution) + .build(); + } + + /** + * 상품별 리뷰 요약 정보 조회 + */ + public ProductReviewSummaryResponse getProductReviewSummary(Long productId) { + // 평균 평점 조회 + Double averageRating = reviewRepository.findAverageRatingByProductId(productId); + + // 총 리뷰 수 조회 + Long totalReviews = reviewRepository.countByProductIdAndStatus(productId, ReviewStatus.ACTIVE); + + // 평점 분포 생성 + ProductReviewSummaryResponse.RatingDistribution ratingDistribution = + ProductReviewSummaryResponse.RatingDistribution.builder() + .fiveStar(reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 5)) + .fourStar(reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 4)) + .threeStar(reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 3)) + .twoStar(reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 2)) + .oneStar(reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 1)) + .build(); + + return ProductReviewSummaryResponse.builder() + .productId(productId) + .totalReviews(totalReviews) + .averageRating(averageRating != null ? averageRating : 0.0) + .ratingDistribution(ratingDistribution) + .mostCommonSizeFit("PERFECT") // 임시값 + .recentReviews(new ArrayList<>()) // 임시값 + .build(); + } + + /** + * 쿠셔닝별 평균 평점 조회 + */ + public Double getAverageRatingByCushioning(Cushion cushioning) { + return reviewRepository.findAverageRatingByCushioning(cushioning, ReviewStatus.ACTIVE); + } + + /** + * 사이즈 핏별 평균 평점 조회 + */ + public Double getAverageRatingBySizeFit(SizeFit sizeFit) { + return reviewRepository.findAverageRatingBySizeFit(sizeFit, ReviewStatus.ACTIVE); + } + + /** + * 안정성별 평균 평점 조회 + */ + public Double getAverageRatingByStability(Stability stability) { + return reviewRepository.findAverageRatingByStability(stability, ReviewStatus.ACTIVE); + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewBlindRequest.java b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewBlindRequest.java new file mode 100644 index 000000000..b843dda7e --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewBlindRequest.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.request; + +public class ReviewBlindRequest { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewCreateRequest.java b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewCreateRequest.java new file mode 100644 index 000000000..65a630f5e --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewCreateRequest.java @@ -0,0 +1,51 @@ +package com.cMall.feedShop.review.application.dto.request; + +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.Stability; +import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Data; +import java.util.List; + +@Data +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) // toBuilder 옵션 추가 +public class ReviewCreateRequest { + + @NotNull(message = "사용자 ID는 필수입니다") + @Positive(message = "사용자 ID는 양수여야 합니다") + private Long userId; + + @NotNull(message = "상품 ID는 필수입니다") + @Positive(message = "상품 ID는 양수여야 합니다") + private Long productId; + + @Size(max = 100, message = "리뷰 제목은 100자를 초과할 수 없습니다") + private String reviewTitle; + + @NotNull(message = "평점은 필수입니다") + @Min(value = 1, message = "평점은 1점 이상이어야 합니다") + @Max(value = 5, message = "평점은 5점 이하여야 합니다") + private Integer rating; + + @Size(max = 1000, message = "리뷰 내용은 1000자를 초과할 수 없습니다") + private String content; + + @NotNull(message = "사이즈 핏은 필수입니다") + private SizeFit sizeFit; + + @NotNull(message = "쿠셔닝은 필수입니다") + private Cushion cushioning; + + @NotNull(message = "안정성은 필수입니다") + private Stability stability; + + @Size(max = 5, message = "이미지는 최대 5개까지 업로드 가능합니다") + private List imageUrls; +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewFilterRequest.java b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewFilterRequest.java new file mode 100644 index 000000000..4c1be5243 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewFilterRequest.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.request; + +public class ReviewFilterRequest { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewImageOrderRequest.java b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewImageOrderRequest.java new file mode 100644 index 000000000..a15fb84e9 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewImageOrderRequest.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.request; + +public class ReviewImageOrderRequest { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewReportRequest.java b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewReportRequest.java new file mode 100644 index 000000000..cf2b7c1ff --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewReportRequest.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.request; + +public class ReviewReportRequest { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewSearchRequest.java b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewSearchRequest.java new file mode 100644 index 000000000..a2ae72b6b --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewSearchRequest.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.request; + +public class ReviewSearchRequest { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewUpdateRequest.java b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewUpdateRequest.java new file mode 100644 index 000000000..2203a75df --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/request/ReviewUpdateRequest.java @@ -0,0 +1,38 @@ +package com.cMall.feedShop.review.application.dto.request; + +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.Stability; +import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ReviewUpdateRequest { + + @Size(max = 100, message = "리뷰 제목은 100자를 초과할 수 없습니다") + private String reviewTitle; + + @Min(value = 1, message = "평점은 1점 이상이어야 합니다") + @Max(value = 5, message = "평점은 5점 이하여야 합니다") + private Integer rating; + + @Size(max = 1000, message = "리뷰 내용은 1000자를 초과할 수 없습니다") + private String content; + + private SizeFit sizeFit; + + private Cushion cushioning; + + private Stability stability; + + @Size(max = 5, message = "이미지는 최대 5개까지 업로드 가능합니다") + private List imageUrls; +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ProductReviewSummaryResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ProductReviewSummaryResponse.java new file mode 100644 index 000000000..a67f4e921 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ProductReviewSummaryResponse.java @@ -0,0 +1,36 @@ +// 상품 전체 리뷰 통계 + +package com.cMall.feedShop.review.application.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ProductReviewSummaryResponse { + + private Long productId; + private Long totalReviews; + private Double averageRating; + private RatingDistribution ratingDistribution; + private String mostCommonSizeFit; // 가장 많이 선택된 사이즈 핏 + private List recentReviews; // 기존 ReviewSummaryResponse 사용 + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class RatingDistribution { + private Long fiveStar; + private Long fourStar; + private Long threeStar; + private Long twoStar; + private Long oneStar; + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewBlindResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewBlindResponse.java new file mode 100644 index 000000000..9c9b82daf --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewBlindResponse.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.response; + +public class ReviewBlindResponse { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewCountResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewCountResponse.java new file mode 100644 index 000000000..f477cf9a8 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewCountResponse.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.response; + +public class ReviewCountResponse { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewCreateResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewCreateResponse.java new file mode 100644 index 000000000..7a06145cb --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewCreateResponse.java @@ -0,0 +1,37 @@ +package com.cMall.feedShop.review.application.dto.response; + +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Stability; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReviewCreateResponse { + private Long reviewId; + private Long userId; + private Long productId; + private Long orderItemId; + private String reviewTitle; + private String content; + private Integer rating; + private SizeFit sizeFit; + private Cushion cushioning; + private Stability stability; + private Integer points; + private Integer reportCount; + private Boolean isBlinded; + private Boolean hasDetailedContent; + private String status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private List imageUrls; + private LocalDateTime deletedAt; +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewDetailResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewDetailResponse.java new file mode 100644 index 000000000..46e01e0a5 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewDetailResponse.java @@ -0,0 +1,33 @@ +package com.cMall.feedShop.review.application.dto.response; + +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Stability; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ReviewDetailResponse { + + private Long reviewId; + private Long productId; + private Long userId; + private String userName; + private String reviewTitle; + private Integer rating; + private String content; + private SizeFit sizeFit; + private Cushion cushioning; + private Stability stability; + private List imageUrls; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewImageOrderResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewImageOrderResponse.java new file mode 100644 index 000000000..3830733df --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewImageOrderResponse.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.response; + +public class ReviewImageOrderResponse { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewImageResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewImageResponse.java new file mode 100644 index 000000000..31f82dcfe --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewImageResponse.java @@ -0,0 +1,19 @@ +package com.cMall.feedShop.review.application.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReviewImageResponse { + private Long imageId; + private Long reviewsId; + private String imageUrl; + private Integer imageOrder; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewImageUploadResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewImageUploadResponse.java new file mode 100644 index 000000000..1e8538268 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewImageUploadResponse.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.response; + +public class ReviewImageUploadResponse { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewRatingResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewRatingResponse.java new file mode 100644 index 000000000..6df2f734e --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewRatingResponse.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.response; + +public class ReviewRatingResponse { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewReportListResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewReportListResponse.java new file mode 100644 index 000000000..66a871d4d --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewReportListResponse.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.response; + +public class ReviewReportListResponse { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewReportResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewReportResponse.java new file mode 100644 index 000000000..e9e608d7a --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewReportResponse.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.response; + +public class ReviewReportResponse { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewSearchResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewSearchResponse.java new file mode 100644 index 000000000..f35e31186 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewSearchResponse.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.response; + +public class ReviewSearchResponse { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewStatisticsResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewStatisticsResponse.java new file mode 100644 index 000000000..cdb0d1f8f --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewStatisticsResponse.java @@ -0,0 +1,29 @@ +package com.cMall.feedShop.review.application.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ReviewStatisticsResponse { + + private Long productId; + + private Double averageRating; + + private Long totalReviews; + + private Map ratingDistribution; + + private Map sizeFitDistribution; + + private Map stabilityDistribution; + + private Map cushioningDistribution; +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewSummaryResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewSummaryResponse.java new file mode 100644 index 000000000..f6c51ec46 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewSummaryResponse.java @@ -0,0 +1,29 @@ +package com.cMall.feedShop.review.application.dto.response; + +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Stability; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReviewSummaryResponse { + private Long reviewId; + private Long userId; + private Long productId; + private String reviewTitle; + private String content; + private Integer rating; + private SizeFit sizeFit; + private Cushion cushioning; + private Stability stability; + private LocalDateTime createdAt; + private List images; // SPRINT2에서 사용, 지금은 빈 리스트 +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewUpdateResponse.java b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewUpdateResponse.java new file mode 100644 index 000000000..f8fa16419 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/dto/response/ReviewUpdateResponse.java @@ -0,0 +1,5 @@ +package com.cMall.feedShop.review.application.dto.response; + +public class ReviewUpdateResponse { + +} diff --git a/src/main/java/com/cMall/feedShop/review/application/exception/ReviewException.java b/src/main/java/com/cMall/feedShop/review/application/exception/ReviewException.java new file mode 100644 index 000000000..6ffed6b88 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/application/exception/ReviewException.java @@ -0,0 +1,46 @@ +package com.cMall.feedShop.review.application.exception; + +import com.cMall.feedShop.common.exception.BusinessException; +import com.cMall.feedShop.common.exception.ErrorCode; + +public class ReviewException extends BusinessException { + + public ReviewException(ErrorCode errorCode) { + super(errorCode); + } + + public ReviewException(ErrorCode errorCode, String message) { + super(errorCode, message); + } + + // 미리 정의된 예외들 (ErrorCode 기반) + public static class ReviewNotFoundException extends ReviewException { + public ReviewNotFoundException() { + super(ErrorCode.REVIEW_NOT_FOUND); + } + } + + public static class ReviewAlreadyExistsException extends ReviewException { + public ReviewAlreadyExistsException() { + super(ErrorCode.REVIEW_ALREADY_EXISTS); + } + } + + public static class ReviewAccessDeniedException extends ReviewException { + public ReviewAccessDeniedException() { + super(ErrorCode.REVIEW_ACCESS_DENIED); + } + } + + public static class InvalidRatingException extends ReviewException { + public InvalidRatingException() { + super(ErrorCode.INVALID_RATING); + } + } + + public static class ReviewContentTooLongException extends ReviewException { + public ReviewContentTooLongException() { + super(ErrorCode.REVIEW_CONTENT_TOO_LONG); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/domain/entity/Cushion.java b/src/main/java/com/cMall/feedShop/review/domain/entity/Cushion.java new file mode 100644 index 000000000..85a91af2c --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/domain/entity/Cushion.java @@ -0,0 +1,26 @@ +package com.cMall.feedShop.review.domain.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Cushion { + VERY_SOFT("very_soft", "매우 부드러움"), + SOFT("soft", "부드러움"), + NORMAL("normal", "보통"), + FIRM("firm", "단단함"), + VERY_FIRM("very_firm", "매우 단단함"); + + private final String code; + private final String description; + + public static Cushion fromCode(String code) { + for (Cushion cushion : values()) { + if (cushion.getCode().equals(code)) { + return cushion; + } + } + throw new IllegalArgumentException("Unknown Cushion code: " + code); + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/domain/entity/Review.java b/src/main/java/com/cMall/feedShop/review/domain/entity/Review.java new file mode 100644 index 000000000..fc338383f --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/domain/entity/Review.java @@ -0,0 +1,148 @@ +package com.cMall.feedShop.review.domain.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.Stability; +import java.time.LocalDateTime; + +@Entity +@Table(name = "reviews") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Review { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "review_id") + private Long reviewId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "review_title", length = 100) + private String reviewTitle; + + @Column(name = "rating", nullable = false) + private Integer rating; + + @Column(name = "content", length = 1000) + private String content; + + @Enumerated(EnumType.STRING) + @Column(name = "size_fit") + private SizeFit sizeFit; + + @Enumerated(EnumType.STRING) + @Column(name = "cushion") // DB 컬럼명에 맞춤 + private Cushion cushioning; // Java 필드명은 그대로 + + @Enumerated(EnumType.STRING) + @Column(name = "stability") + private Stability stability; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + @Builder.Default + private ReviewStatus status = ReviewStatus.ACTIVE; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + // id 편의 메서드 추가 (테스트 호환성을 위해) + public Long getId() { + return this.reviewId; + } + + // 비즈니스 메서드 + public void updateTitle(String reviewTitle) { + validateTitle(reviewTitle); + this.reviewTitle = reviewTitle; + } + + public void updateContent(String content) { + validateContent(content); + this.content = content; + } + + public void updateRating(Integer rating) { + validateRating(rating); + this.rating = rating; + } + + public void updateSizeFit(SizeFit sizeFit) { + this.sizeFit = sizeFit; + } + + public void updateCushioning(Cushion cushioning) { + this.cushioning = cushioning; + } + + public void updateStability(Stability stability) { + this.stability = stability; + } + + public void delete() { + this.status = ReviewStatus.DELETED; + } + + public void restore() { + this.status = ReviewStatus.ACTIVE; + } + + public void hide() { + this.status = ReviewStatus.HIDDEN; + } + + public boolean isActive() { + return this.status == ReviewStatus.ACTIVE; + } + + public boolean isDeleted() { + return this.status == ReviewStatus.DELETED; + } + + public boolean isHidden() { + return this.status == ReviewStatus.HIDDEN; + } + + // 검증 메서드들 + public static void validateRating(Integer rating) { + if (rating == null || rating < 1 || rating > 5) { + throw new IllegalArgumentException("평점은 1점에서 5점 사이여야 합니다."); + } + } + + public static void validateContent(String content) { + if (content != null && content.length() > 1000) { + throw new IllegalArgumentException("리뷰 내용은 1000자를 초과할 수 없습니다."); + } + } + + public static void validateTitle(String title) { + if (title != null && title.length() > 100) { + throw new IllegalArgumentException("리뷰 제목은 100자를 초과할 수 없습니다."); + } + } + + // getter 메서드 (Lombok이 생성하지만 명시적으로 필요한 경우) + public String getReviewTitle() { + return this.reviewTitle; + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/domain/entity/ReviewImage.java b/src/main/java/com/cMall/feedShop/review/domain/entity/ReviewImage.java new file mode 100644 index 000000000..5ee029131 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/domain/entity/ReviewImage.java @@ -0,0 +1,42 @@ +package com.cMall.feedShop.review.domain.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "review_images") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ReviewImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "image_id") + private Long imageId; + + @Column(name = "review_id", nullable = false) + private Long reviewId; + + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; + + @Column(name = "image_order") + private Integer imageOrder; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + // 연관관계 편의 메서드 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", insertable = false, updatable = false) + private Review review; +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/domain/entity/ReviewStatus.java b/src/main/java/com/cMall/feedShop/review/domain/entity/ReviewStatus.java new file mode 100644 index 000000000..68956b46b --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/domain/entity/ReviewStatus.java @@ -0,0 +1,17 @@ +package com.cMall.feedShop.review.domain.entity; + +public enum ReviewStatus { + ACTIVE("활성"), + DELETED("삭제됨"), + HIDDEN("숨김"); + + private final String description; + + ReviewStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/domain/entity/SizeFit.java b/src/main/java/com/cMall/feedShop/review/domain/entity/SizeFit.java new file mode 100644 index 000000000..34468d0af --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/domain/entity/SizeFit.java @@ -0,0 +1,26 @@ +package com.cMall.feedShop.review.domain.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SizeFit { + VERY_SMALL("very_small", "매우 작음"), + SMALL("small", "작음"), + PERFECT("perfect", "딱 맞음"), + BIG("big", "큼"), + VERY_BIG("very_big", "매우 큼"); + + private final String code; + private final String description; + + public static SizeFit fromCode(String code) { + for (SizeFit sizeFit : values()) { + if (sizeFit.getCode().equals(code)) { + return sizeFit; + } + } + throw new IllegalArgumentException("Unknown SizeFit code: " + code); + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/domain/entity/Stability.java b/src/main/java/com/cMall/feedShop/review/domain/entity/Stability.java new file mode 100644 index 000000000..cf1249f48 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/domain/entity/Stability.java @@ -0,0 +1,26 @@ +package com.cMall.feedShop.review.domain.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Stability { + VERY_UNSTABLE("very_unstable", "매우 불안정"), + UNSTABLE("unstable", "불안정"), + NORMAL("normal", "보통"), + STABLE("stable", "안정적"), + VERY_STABLE("very_stable", "매우 안정적"); + + private final String code; + private final String description; + + public static Stability fromCode(String code) { + for (Stability stability : values()) { + if (stability.getCode().equals(code)) { + return stability; + } + } + throw new IllegalArgumentException("Unknown Stability code: " + code); + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/domain/repository/ReviewImageRepository.java b/src/main/java/com/cMall/feedShop/review/domain/repository/ReviewImageRepository.java new file mode 100644 index 000000000..2f7d5bfb5 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/domain/repository/ReviewImageRepository.java @@ -0,0 +1,47 @@ +package com.cMall.feedShop.review.domain.repository; + +import com.cMall.feedShop.review.domain.entity.ReviewImage; + +import java.util.List; +import java.util.Optional; + +/** + * 리뷰 이미지 도메인 Repository 인터페이스 + */ +public interface ReviewImageRepository { + + /** + * 이미지 저장 + */ + ReviewImage save(ReviewImage reviewImage); + + /** + * ID로 이미지 조회 + */ + Optional findById(Long imageId); + + /** + * 리뷰별 이미지 목록 조회 (순서대로) + */ + List findByReviewIdOrderByImageOrder(Long reviewId); + + /** + * 리뷰별 이미지 개수 조회 + */ + Long countByReviewId(Long reviewId); + + /** + * 이미지 삭제 + */ + void deleteById(Long imageId); + + /** + * 리뷰별 모든 이미지 삭제 + */ + void deleteByReviewId(Long reviewId); + + /** + * 이미지 소유권 확인 + */ + boolean existsByIdAndReviewUserId(Long imageId, Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/domain/repository/ReviewRepository.java b/src/main/java/com/cMall/feedShop/review/domain/repository/ReviewRepository.java new file mode 100644 index 000000000..ae398607b --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/domain/repository/ReviewRepository.java @@ -0,0 +1,112 @@ +package com.cMall.feedShop.review.domain.repository; + +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Stability; +import com.cMall.feedShop.review.domain.entity.Review; +import com.cMall.feedShop.review.domain.entity.ReviewStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 리뷰 도메인 Repository 인터페이스 + * 순수한 도메인 개념 - 기술에 의존하지 않음 + */ +public interface ReviewRepository extends JpaRepository { + + /** + * 리뷰 저장 + */ + Review save(Review review); + + /** + * ID로 리뷰 조회 + */ + Optional findById(Long reviewId); + + /** + * 리뷰 삭제 (물리적 삭제) + */ + void deleteById(Long reviewId); + + /** + * 모든 리뷰 조회 (관리자용) + */ + Page findAll(Pageable pageable); + /** + * 상품별 활성 리뷰 목록 조회 (페이징) + */ + Page findByProductIdAndStatus(Long productId, ReviewStatus status, Pageable pageable); + + /** + * 사용자별 리뷰 목록 조회 (페이징) + */ + Page findByUserIdAndStatus(Long userId, ReviewStatus status, Pageable pageable); + + /** + * 상품별 활성 리뷰 개수 조회 + */ + Long countByProductIdAndStatus(Long productId, ReviewStatus status); + + /** + * 평점별 개수 조회 + */ + Long countByProductIdAndStatusAndRating(Long productId, ReviewStatus status, Integer rating); + + // ===== @Query가 필요한 메서드들 ===== + + /** + * 중복 리뷰 존재 여부 확인 + */ + @Query("SELECT CASE WHEN COUNT(r) > 0 THEN true ELSE false END FROM Review r WHERE r.userId = :userId AND r.productId = :productId AND r.status = com.cMall.feedShop.review.domain.entity.ReviewStatus.ACTIVE") + boolean existsByUserIdAndProductIdAndStatusActive(@Param("userId") Long userId, @Param("productId") Long productId); + + /** + * 상품별 평균 평점 조회 + */ + @Query("SELECT AVG(CAST(r.rating AS double)) FROM Review r WHERE r.productId = :productId AND r.status = com.cMall.feedShop.review.domain.entity.ReviewStatus.ACTIVE") + Double findAverageRatingByProductId(@Param("productId") Long productId); + + /** + * 쿠셔닝별 평균 평점 조회 + */ + @Query("SELECT AVG(CAST(r.rating AS double)) FROM Review r WHERE r.cushioning = :cushioning AND r.status = :status") + Double findAverageRatingByCushioning(@Param("cushioning") Cushion cushioning, @Param("status") ReviewStatus status); + + /** + * 사이즈 핏별 평균 평점 조회 + */ + @Query("SELECT AVG(CAST(r.rating AS double)) FROM Review r WHERE r.sizeFit = :sizeFit AND r.status = :status") + Double findAverageRatingBySizeFit(@Param("sizeFit") SizeFit sizeFit, @Param("status") ReviewStatus status); + + /** + * 안정성별 평균 평점 조회 + */ + @Query("SELECT AVG(CAST(r.rating AS double)) FROM Review r WHERE r.stability = :stability AND r.status = :status") + Double findAverageRatingByStability(@Param("stability") Stability stability, @Param("status") ReviewStatus status); + + /** + * 특정 조건으로 리뷰 필터링 조회 + */ + @Query("SELECT r FROM Review r WHERE r.productId = :productId AND r.sizeFit = :sizeFit AND r.status = :status") + List findByProductIdAndSizeFitAndStatus(@Param("productId") Long productId, + @Param("sizeFit") SizeFit sizeFit, + @Param("status") ReviewStatus status); + + @Query("SELECT r FROM Review r WHERE r.productId = :productId AND r.cushioning = :cushioning AND r.status = :status") + List findByProductIdAndCushioningAndStatus(@Param("productId") Long productId, + @Param("cushioning") Cushion cushioning, + @Param("status") ReviewStatus status); + + @Query("SELECT r FROM Review r WHERE r.productId = :productId AND r.stability = :stability AND r.status = :status") + List findByProductIdAndStabilityAndStatus(@Param("productId") Long productId, + @Param("stability") Stability stability, + @Param("status") ReviewStatus status); +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/infrastructure/ReviewImageRepositoryImpl.java b/src/main/java/com/cMall/feedShop/review/infrastructure/ReviewImageRepositoryImpl.java new file mode 100644 index 000000000..3410d1770 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/infrastructure/ReviewImageRepositoryImpl.java @@ -0,0 +1,65 @@ +package com.cMall.feedShop.review.infrastructure; + +import com.cMall.feedShop.review.domain.entity.ReviewImage; +import com.cMall.feedShop.review.domain.repository.ReviewImageRepository; // 🔥 도메인 인터페이스 import +import com.cMall.feedShop.review.infrastructure.jpa.ReviewImageJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 리뷰 이미지 Repository 구현체 + * Domain의 ReviewImageRepository 인터페이스를 Infrastructure에서 구현 + */ +@Slf4j +@Repository +@RequiredArgsConstructor +public class ReviewImageRepositoryImpl implements ReviewImageRepository { // 🔥 도메인 인터페이스 구현 + + private final ReviewImageJpaRepository jpaRepository; // JPA Repository 사용 + + @Override + public ReviewImage save(ReviewImage reviewImage) { + log.debug("리뷰 이미지 저장 - imageId: {}", reviewImage.getImageId()); + return jpaRepository.save(reviewImage); + } + + @Override + public Optional findById(Long imageId) { + log.debug("리뷰 이미지 조회 - imageId: {}", imageId); + return jpaRepository.findById(imageId); + } + + @Override + public List findByReviewIdOrderByImageOrder(Long reviewId) { + log.debug("리뷰별 이미지 목록 조회 - reviewId: {}", reviewId); + return jpaRepository.findByReviewIdOrderByImageOrder(reviewId); + } + + @Override + public Long countByReviewId(Long reviewId) { + log.debug("리뷰별 이미지 개수 조회 - reviewId: {}", reviewId); + return jpaRepository.countByReviewId(reviewId); + } + + @Override + public void deleteById(Long imageId) { + log.debug("리뷰 이미지 삭제 - imageId: {}", imageId); + jpaRepository.deleteById(imageId); + } + + @Override + public void deleteByReviewId(Long reviewId) { + log.debug("리뷰별 모든 이미지 삭제 - reviewId: {}", reviewId); + jpaRepository.deleteByReviewId(reviewId); + } + + @Override + public boolean existsByIdAndReviewUserId(Long imageId, Long userId) { + log.debug("이미지 소유권 확인 - imageId: {}, userId: {}", imageId, userId); + return jpaRepository.existsByImageIdAndReviewUserId(imageId, userId); + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewImageJpaRepository.java b/src/main/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewImageJpaRepository.java new file mode 100644 index 000000000..3b6897313 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewImageJpaRepository.java @@ -0,0 +1,40 @@ +package com.cMall.feedShop.review.infrastructure.jpa; + +import com.cMall.feedShop.review.domain.entity.ReviewImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * 리뷰 이미지 JPA Repository + */ +public interface ReviewImageJpaRepository extends JpaRepository { + + /** + * 리뷰별 이미지 목록 조회 (순서대로) + */ + @Query("SELECT ri FROM ReviewImage ri WHERE ri.review.reviewId = :reviewId ORDER BY ri.imageOrder ASC") + List findByReviewIdOrderByImageOrder(@Param("reviewId") Long reviewId); + + /** + * 리뷰별 이미지 개수 + */ + @Query("SELECT COUNT(ri) FROM ReviewImage ri WHERE ri.review.reviewId = :reviewId") + Long countByReviewId(@Param("reviewId") Long reviewId); + + /** + * 리뷰별 모든 이미지 삭제 + */ + @Modifying + @Query("DELETE FROM ReviewImage ri WHERE ri.review.reviewId = :reviewId") + void deleteByReviewId(@Param("reviewId") Long reviewId); + + /** + * 이미지 소유권 확인 + */ + @Query("SELECT COUNT(ri) > 0 FROM ReviewImage ri WHERE ri.imageId = :imageId AND ri.review.userId = :userId") + boolean existsByImageIdAndReviewUserId(@Param("imageId") Long imageId, @Param("userId") Long userId); +} diff --git a/src/main/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewJpaRepository.java b/src/main/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewJpaRepository.java new file mode 100644 index 000000000..86c5c2a36 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewJpaRepository.java @@ -0,0 +1,49 @@ +package com.cMall.feedShop.review.infrastructure.jpa; + +import com.cMall.feedShop.review.domain.entity.Review; +import com.cMall.feedShop.review.domain.entity.ReviewStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface ReviewJpaRepository extends JpaRepository { + + // 상품별 리뷰 조회 (상태별, 생성일 내림차순) + Page findByProductIdAndStatusOrderByCreatedAtDesc( + Long productId, + ReviewStatus status, + Pageable pageable + ); + + // 사용자별 리뷰 조회 (상태별, 생성일 내림차순) + Page findByUserIdAndStatusOrderByCreatedAtDesc( + Long userId, + ReviewStatus status, + Pageable pageable + ); + + // 상품별 리뷰 개수 조회 + Long countByProductIdAndStatus(Long productId, ReviewStatus status); + + // 리뷰 ID로 조회 (삭제되지 않은 것만) + @Query("SELECT r FROM Review r WHERE r.reviewId = :reviewId AND r.status != 'DELETED'") + Optional findByReviewIdAndStatusNotDeleted(@Param("reviewId") Long reviewId); + + // 사용자가 해당 상품에 대해 활성 리뷰를 작성했는지 확인 + @Query("SELECT CASE WHEN COUNT(r) > 0 THEN true ELSE false END FROM Review r WHERE r.userId = :userId AND r.productId = :productId AND r.status = 'ACTIVE'") + boolean existsByUserIdAndProductIdAndStatusActive(@Param("userId") Long userId, @Param("productId") Long productId); + + // 리뷰 ID와 사용자 ID로 소유권 확인 + @Query("SELECT CASE WHEN COUNT(r) > 0 THEN true ELSE false END FROM Review r WHERE r.reviewId = :reviewId AND r.userId = :userId") + boolean existsByReviewIdAndUserId(@Param("reviewId") Long reviewId, @Param("userId") Long userId); + + // 상품별 평균 평점 조회 + @Query("SELECT AVG(r.rating) FROM Review r WHERE r.productId = :productId AND r.status = 'ACTIVE'") + Double findAverageRatingByProductId(@Param("productId") Long productId); + + Long countByProductIdAndStatusAndRating(Long productId, ReviewStatus status, Integer rating); +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/presentation/ReviewApi.java b/src/main/java/com/cMall/feedShop/review/presentation/ReviewApi.java new file mode 100644 index 000000000..4e5c1845e --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/presentation/ReviewApi.java @@ -0,0 +1,86 @@ +package com.cMall.feedShop.review.presentation; + +import com.cMall.feedShop.common.dto.ApiResponse; +import com.cMall.feedShop.review.application.dto.request.ReviewCreateRequest; +import com.cMall.feedShop.review.application.dto.request.ReviewUpdateRequest; +import com.cMall.feedShop.review.application.dto.response.ReviewCreateResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewDetailResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewSummaryResponse; +import com.cMall.feedShop.review.application.dto.response.ProductReviewSummaryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import java.util.Map; + +@Tag(name = "리뷰 관리 API", description = "리뷰 등록, 수정, 삭제, 조회 관련 API") +@Validated +public interface ReviewApi { + + @Operation(summary = "리뷰 등록", description = "새로운 리뷰를 등록합니다.") + ResponseEntity> createReview( + @Valid @RequestBody ReviewCreateRequest request + ); + + @Operation(summary = "리뷰 수정", description = "기존 리뷰의 제목, 내용, 평점을 수정합니다.") + ResponseEntity> updateReview( + @PathVariable @Min(1) @Parameter(description = "리뷰 ID") Long reviewId, + @Valid @RequestBody ReviewUpdateRequest request + ); + + @Operation(summary = "리뷰 삭제", description = "리뷰를 논리 삭제합니다.") + ResponseEntity> deleteReview( + @PathVariable @Min(1) @Parameter(description = "리뷰 ID") Long reviewId, + @RequestParam @Min(1) @Parameter(description = "사용자 ID") Long userId + ); + + @Operation(summary = "리뷰 비활성화", description = "리뷰를 비활성화합니다.") + ResponseEntity> deactivateReview( + @PathVariable @Min(1) @Parameter(description = "리뷰 ID") Long reviewId, + @RequestParam @Min(1) @Parameter(description = "사용자 ID") Long userId + ); + + @Operation(summary = "리뷰 단건 조회", description = "특정 리뷰의 상세 정보를 조회합니다.") + ResponseEntity> getReview( + @PathVariable @Min(1) @Parameter(description = "리뷰 ID") Long reviewId + ); + + @Operation(summary = "상품별 리뷰 목록 조회", description = "특정 상품의 리뷰 목록을 페이징하여 조회합니다.") + ResponseEntity> getReviewsByProductId( + @PathVariable @Min(1) @Parameter(description = "상품 ID") Long productId, + @RequestParam(defaultValue = "false") @Parameter(description = "요약 모드 여부") boolean summary, + @Parameter(description = "페이징 정보") Pageable pageable + ); + + @Operation(summary = "사용자별 리뷰 목록 조회", description = "특정 사용자가 작성한 리뷰 목록을 페이징하여 조회합니다.") + ResponseEntity>> getReviewsByUserId( + @PathVariable @Min(1) @Parameter(description = "사용자 ID") Long userId, + @Parameter(description = "페이징 정보") Pageable pageable + ); + + @Operation(summary = "리뷰 검색", description = "평점 범위와 키워드를 이용하여 리뷰를 검색합니다.") + ResponseEntity>> searchReviews( + @RequestParam @Min(1) @Parameter(description = "상품 ID") Long productId, + @RequestParam(required = false) @Parameter(description = "최소 평점") Integer minRating, + @RequestParam(required = false) @Parameter(description = "최대 평점") Integer maxRating, + @RequestParam(required = false) @Parameter(description = "검색 키워드") String keyword, + @Parameter(description = "페이징 정보") Pageable pageable + ); + + @Operation(summary = "상품 리뷰 통계", description = "특정 상품의 평균 평점과 리뷰 개수를 조회합니다.") + ResponseEntity>> getReviewStatistics( + @PathVariable @Min(1) @Parameter(description = "상품 ID") Long productId + ); + + @Operation(summary = "상품 평균 평점 조회", description = "특정 상품의 평균 평점을 조회합니다.") + ResponseEntity> getAverageRating( + @PathVariable @Min(1) @Parameter(description = "상품 ID") Long productId + ); +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/presentation/ReviewController.java b/src/main/java/com/cMall/feedShop/review/presentation/ReviewController.java new file mode 100644 index 000000000..0cf761939 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/presentation/ReviewController.java @@ -0,0 +1,42 @@ +package com.cMall.feedShop.review.presentation; +import com.cMall.feedShop.review.application.dto.request.*; +import com.cMall.feedShop.review.application.dto.response.ReviewDetailResponse; +import com.cMall.feedShop.review.application.ReviewService; +import com.cMall.feedShop.common.aop.ApiResponseFormat; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.constraints.Min; + +/** + * SPRINT1 - 리뷰 범용 API 컨트롤러 + * RE-03: 리뷰 상세 조회 + */ +@Slf4j +@RestController +@RequestMapping("/api/reviews") +@RequiredArgsConstructor +@Tag(name = "Review", description = "리뷰 범용 API - SPRINT1") +public class ReviewController { + + private final ReviewService reviewService; + + /** + * RE-03: 리뷰 상세 조회 + * 특정 리뷰의 상세 정보를 조회합니다. + */ + @ApiResponseFormat(message = "리뷰가 성공적으로 조회되었습니다.") + @GetMapping("/{reviewId}") + @Operation(summary = "리뷰 상세 조회", description = "특정 리뷰의 상세 정보를 조회합니다.") + public ReviewDetailResponse getReviewDetail( + @PathVariable @Min(1) Long reviewId) { + + log.info("리뷰 상세 조회 요청 - reviewId: {}", reviewId); + return reviewService.getReviewDetail(reviewId); + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/review/presentation/ReviewProductController.java b/src/main/java/com/cMall/feedShop/review/presentation/ReviewProductController.java new file mode 100644 index 000000000..addb13d20 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/presentation/ReviewProductController.java @@ -0,0 +1,49 @@ +package com.cMall.feedShop.review.presentation; + +import com.cMall.feedShop.review.application.dto.request.*; +import com.cMall.feedShop.review.application.dto.response.ProductReviewSummaryResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewSummaryResponse; +import com.cMall.feedShop.review.application.ReviewService; +import com.cMall.feedShop.common.aop.ApiResponseFormat; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.http.ResponseEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.constraints.Min; + +/** + * SPRINT1 - 상품 중심 리뷰 API 컨트롤러 + * RE-02: 리뷰 목록 조회 (상품별) + */ +@Slf4j +@RestController +@RequestMapping("/api/products/{productId}/reviews") +@RequiredArgsConstructor +@Tag(name = "Product Review", description = "상품 중심 리뷰 API - SPRINT1") +public class ReviewProductController { + + private final ReviewService reviewService; + + /** + * RE-02: 리뷰 목록 조회 (상품별) + * 특정 상품의 리뷰 목록을 페이징하여 조회합니다. + */ + @ApiResponseFormat(message = "상품별 리뷰 목록이 성공적으로 조회되었습니다.") + @GetMapping + @Operation(summary = "상품별 리뷰 목록 조회", description = "특정 상품의 리뷰 목록을 페이징하여 조회합니다.") + public ResponseEntity getProductReviews( + @PathVariable Long productId, + Pageable pageable) { + + ProductReviewSummaryResponse reviews = reviewService.getProductReviews(productId, pageable); + return ResponseEntity.ok(reviews); +} +} diff --git a/src/main/java/com/cMall/feedShop/review/presentation/ReviewUserController.java b/src/main/java/com/cMall/feedShop/review/presentation/ReviewUserController.java new file mode 100644 index 000000000..9220cf516 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/review/presentation/ReviewUserController.java @@ -0,0 +1,47 @@ +package com.cMall.feedShop.review.presentation; + +import com.cMall.feedShop.review.application.dto.request.ReviewCreateRequest; +import com.cMall.feedShop.review.application.dto.response.ReviewCreateResponse; +import com.cMall.feedShop.review.application.ReviewService; +import com.cMall.feedShop.common.aop.ApiResponseFormat; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +/** + * SPRINT1 - 사용자 중심 리뷰 API 컨트롤러 + * RE-01: 리뷰 작성 + */ +@Slf4j +@RestController +@RequestMapping("/api/users/{userId}/reviews") +@RequiredArgsConstructor +@Tag(name = "User Review", description = "사용자 중심 리뷰 API - SPRINT1") +public class ReviewUserController { + + private final ReviewService reviewService; + + /** + * RE-01: 리뷰 작성 + * 새로운 리뷰를 작성합니다. + */ + @ApiResponseFormat(message = "리뷰가 성공적으로 등록되었습니다.") + @PostMapping + @PreAuthorize("#userId == authentication.principal.userId or hasRole('ADMIN')") + @Operation(summary = "리뷰 등록", description = "새로운 리뷰를 등록합니다.") + public ReviewCreateResponse createReview( + @PathVariable @Min(1) Long userId, + @Valid @RequestBody ReviewCreateRequest request) { + + log.info("리뷰 등록 요청 - 사용자: {}, 상품: {}", userId, request.getProductId()); + return reviewService.createReview(request); + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/user/application/dto/request/PasswordChangeRequest.java b/src/main/java/com/cMall/feedShop/user/application/dto/request/PasswordChangeRequest.java new file mode 100644 index 000000000..0fb5ee744 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/dto/request/PasswordChangeRequest.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.application.dto.request; + +public class PasswordChangeRequest { +} diff --git a/src/main/java/com/cMall/feedShop/user/application/dto/request/ProfileUpdateRequest.java b/src/main/java/com/cMall/feedShop/user/application/dto/request/ProfileUpdateRequest.java new file mode 100644 index 000000000..9b41f7021 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/dto/request/ProfileUpdateRequest.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.application.dto.request; + +public class ProfileUpdateRequest { +} diff --git a/src/main/java/com/cMall/feedShop/user/application/dto/request/SocialLoginRequest.java b/src/main/java/com/cMall/feedShop/user/application/dto/request/SocialLoginRequest.java new file mode 100644 index 000000000..256f48e9c --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/dto/request/SocialLoginRequest.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.application.dto.request; + +public class SocialLoginRequest { +} diff --git a/src/main/java/com/cMall/feedShop/user/application/dto/request/UserLoginRequest.java b/src/main/java/com/cMall/feedShop/user/application/dto/request/UserLoginRequest.java new file mode 100644 index 000000000..1f58212a8 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/dto/request/UserLoginRequest.java @@ -0,0 +1,15 @@ +package com.cMall.feedShop.user.application.dto.request; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@NoArgsConstructor +@ToString +@Getter +@Setter +public class UserLoginRequest { + private String email; + private String password; +} diff --git a/src/main/java/com/cMall/feedShop/user/application/dto/request/UserSignUpRequest.java b/src/main/java/com/cMall/feedShop/user/application/dto/request/UserSignUpRequest.java new file mode 100644 index 000000000..50f3c26f9 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/dto/request/UserSignUpRequest.java @@ -0,0 +1,13 @@ +package com.cMall.feedShop.user.application.dto.request; + +import com.cMall.feedShop.annotation.CustomEncryption; +import lombok.Getter; + +@Getter +public class UserSignUpRequest { + private String loginId; + @CustomEncryption + private String password; + private String email; + private String phone; +} diff --git a/src/main/java/com/cMall/feedShop/user/application/dto/response/ProfileResponse.java b/src/main/java/com/cMall/feedShop/user/application/dto/response/ProfileResponse.java new file mode 100644 index 000000000..c76412a9a --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/dto/response/ProfileResponse.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.application.dto.response; + +public class ProfileResponse { +} diff --git a/src/main/java/com/cMall/feedShop/user/application/dto/response/UserListResponse.java b/src/main/java/com/cMall/feedShop/user/application/dto/response/UserListResponse.java new file mode 100644 index 000000000..8cd35f49f --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/dto/response/UserListResponse.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.application.dto.response; + +public class UserListResponse { +} diff --git a/src/main/java/com/cMall/feedShop/user/application/dto/response/UserLoginResponse.java b/src/main/java/com/cMall/feedShop/user/application/dto/response/UserLoginResponse.java new file mode 100644 index 000000000..ad20a8e6e --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/dto/response/UserLoginResponse.java @@ -0,0 +1,16 @@ +package com.cMall.feedShop.user.application.dto.response; + +import com.cMall.feedShop.user.domain.enums.UserRole; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@AllArgsConstructor +@Getter +@Builder +public class UserLoginResponse { + private String loginId; // 사용자의 로그인 ID + private UserRole role; + private String token; + private String nickname; +} diff --git a/src/main/java/com/cMall/feedShop/user/application/dto/response/UserProfileResponse.java b/src/main/java/com/cMall/feedShop/user/application/dto/response/UserProfileResponse.java new file mode 100644 index 000000000..355acdff1 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/dto/response/UserProfileResponse.java @@ -0,0 +1,40 @@ +package com.cMall.feedShop.user.application.dto.response; + +import com.cMall.feedShop.user.domain.model.User; +import com.cMall.feedShop.user.domain.model.UserProfile; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Builder +public class UserProfileResponse { + private Long userId; + private String username; + private String email; + private String nickname; +// private String profileImageUrl; // 프로필 이미지 URL +// private String bio; // 자기소개 + + // 엔티티를 DTO로 변환하는 정적 팩토리 메서드 (권장되는 패턴) + public static UserProfileResponse from(User user, UserProfile userProfile) { + return UserProfileResponse.builder() + .userId(user.getId()) + .username(user.getUsername()) + .email(user.getEmail()) + .nickname(userProfile != null ? userProfile.getNickname() : null) +// .profileImageUrl(userProfile != null ? userProfile.getProfileImageUrl() : null) +// .bio(userProfile != null ? userProfile.getBio() : null) + .build(); + } + + // 또는 User 엔티티만으로도 만들 수 있도록 오버로드 + public static UserProfileResponse from(User user) { + return UserProfileResponse.builder() + .userId(user.getId()) + .username(user.getUsername()) + .email(user.getEmail()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/user/application/dto/response/UserResponse.java b/src/main/java/com/cMall/feedShop/user/application/dto/response/UserResponse.java new file mode 100644 index 000000000..353f09356 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/dto/response/UserResponse.java @@ -0,0 +1,33 @@ +package com.cMall.feedShop.user.application.dto.response; + +import com.cMall.feedShop.user.domain.enums.UserRole; +import com.cMall.feedShop.user.domain.model.User; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class UserResponse { + private Long userId; + private String username; + private String email; + private String phone; + private UserRole role; + private String status; + private LocalDateTime createdAt; + + // User 엔티티를 UserResponse DTO로 변환하는 정적 팩토리 메서드 + public static UserResponse from(User user) { + return UserResponse.builder() + .userId(user.getId()) + .username(user.getUsername()) + .email(user.getEmail()) + .phone(user.getPhone()) + .role(user.getRole()) + .status(user.getStatus().name()) // Enum을 String으로 변환하여 반환 + .createdAt(user.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/cMall/feedShop/user/application/service/BadgeService.java b/src/main/java/com/cMall/feedShop/user/application/service/BadgeService.java new file mode 100644 index 000000000..96cc5dd1f --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/service/BadgeService.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.application.service; + +public class BadgeService { +} diff --git a/src/main/java/com/cMall/feedShop/user/application/service/CouponService.java b/src/main/java/com/cMall/feedShop/user/application/service/CouponService.java new file mode 100644 index 000000000..b2edfef53 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/service/CouponService.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.application.service; + +public class CouponService { +} diff --git a/src/main/java/com/cMall/feedShop/user/application/service/PointService.java b/src/main/java/com/cMall/feedShop/user/application/service/PointService.java new file mode 100644 index 000000000..34af49207 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/service/PointService.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.application.service; + +public class PointService { +} diff --git a/src/main/java/com/cMall/feedShop/user/application/service/SocialLoginService.java b/src/main/java/com/cMall/feedShop/user/application/service/SocialLoginService.java new file mode 100644 index 000000000..63c751332 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/service/SocialLoginService.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.application.service; + +public class SocialLoginService { +} diff --git a/src/main/java/com/cMall/feedShop/user/application/service/UserAuthService.java b/src/main/java/com/cMall/feedShop/user/application/service/UserAuthService.java new file mode 100644 index 000000000..6dcdd5575 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/service/UserAuthService.java @@ -0,0 +1,78 @@ +package com.cMall.feedShop.user.application.service; + +import com.cMall.feedShop.common.exception.ErrorCode; +import com.cMall.feedShop.user.application.dto.request.UserLoginRequest; +import com.cMall.feedShop.user.application.dto.response.UserLoginResponse; +import com.cMall.feedShop.user.domain.model.User; +import com.cMall.feedShop.user.domain.repository.UserRepository; +import com.cMall.feedShop.common.exception.BusinessException; +import com.cMall.feedShop.user.infrastructure.security.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.security.authentication.AuthenticationManager; // AuthenticationManager import 추가 +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; // UsernamePasswordAuthenticationToken import 추가 +import org.springframework.security.core.Authentication; // Authentication import 추가 +import org.springframework.security.core.userdetails.UsernameNotFoundException; // UsernameNotFoundException import 추가 + +@Service +@RequiredArgsConstructor +public class UserAuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtProvider; + private final AuthenticationManager authenticationManager; // AuthenticationManager 주입 + + /** + * 사용자 로그인 처리 메서드. + * 로그인 ID와 비밀번호를 받아 사용자 인증을 수행하고, 성공 시 JWT 토큰을 발급합니다. + * + * @param request 사용자 로그인 요청 (로그인 ID, 비밀번호 포함) + * @return 로그인 응답 (JWT 토큰, 로그인 ID, 사용자 역할 포함) + * @throws BusinessException 사용자가 존재하지 않거나 비밀번호가 일치하지 않을 경우 발생 + */ + public UserLoginResponse login(UserLoginRequest request) { + // 1. Spring Security의 AuthenticationManager를 사용하여 인증 시도 + // React에서 email을 보내고 있으므로, email을 사용자명으로 사용합니다. + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()); + + try { + // AuthenticationManager가 CustomUserDetailsService를 통해 사용자를 로드하고 비밀번호를 검증합니다. + Authentication authentication = authenticationManager.authenticate(authenticationToken); + + // 인증 성공 후, 사용자 정보 로드 (CustomUserDetailsService에서 이미 이메일로 찾았음) + // JWT 토큰 생성에 필요한 정보를 얻기 위해 User 객체를 다시 조회합니다. + User user = userRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND, "존재하지 않는 회원입니다.")); + + // 닉네임 가져오기 (UserProfile이 연관되어 있다면) + String nickname = null; + if (user.getUserProfile() != null) { + nickname = user.getUserProfile().getNickname(); + } + + // 2. 입력된 비밀번호와 저장된 암호화된 비밀번호 비교 (AuthenticationManager가 이미 수행했지만, 명시적으로 다시 확인 가능) + // if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + // throw new BusinessException(ErrorCode.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."); + // } + // 위 코드는 AuthenticationManager.authenticate()가 이미 처리하므로 불필요합니다. + + // 3. 로그인 성공 시, JWT 토큰 발급 + // generateAccessToken 메서드에 email과 role을 직접 전달합니다. + String token = jwtProvider.generateAccessToken(user.getEmail(), user.getRole().name()); + + // 4. 로그인 응답 반환 + return new UserLoginResponse(user.getLoginId(), user.getRole(), token, nickname); + } catch (UsernameNotFoundException e) { + // 사용자를 찾을 수 없을 때 (CustomUserDetailsService에서 발생) + throw new BusinessException(ErrorCode.USER_NOT_FOUND, "존재하지 않는 회원입니다."); + } catch (org.springframework.security.core.AuthenticationException e) { + // 비밀번호 불일치 등 인증 실패 (AuthenticationManager에서 발생) + throw new BusinessException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."); + } + } + + // 기타 인증 관련 메서드 (예: 회원가입, 비밀번호 재설정 등)를 여기에 추가. +} diff --git a/src/main/java/com/cMall/feedShop/user/application/service/UserProfileService.java b/src/main/java/com/cMall/feedShop/user/application/service/UserProfileService.java new file mode 100644 index 000000000..f52c071bc --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/service/UserProfileService.java @@ -0,0 +1,44 @@ +package com.cMall.feedShop.user.application.service; + +import com.cMall.feedShop.user.application.dto.response.UserProfileResponse; +import com.cMall.feedShop.user.domain.model.User; +import com.cMall.feedShop.user.domain.model.UserProfile; +import com.cMall.feedShop.user.domain.repository.UserProfileRepository; +import com.cMall.feedShop.user.domain.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class UserProfileService { + + private final UserRepository userRepository; + private final UserProfileRepository userProfileRepository; + + private static final Logger log = LoggerFactory.getLogger(UserProfileService.class); + + public UserProfileService(UserRepository userRepository, UserProfileRepository userProfileRepository) { + this.userRepository = userRepository; + this.userProfileRepository = userProfileRepository; + } + + public UserProfileResponse getUserProfile(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("User not found with ID: " + userId)); // 사용자가 없으면 예외 발생 + + Optional userProfileOptional = userProfileRepository.findByUser(user); + + // 3. User 엔티티와 UserProfile 엔티티를 UserProfileResponse DTO로 변환하여 반환 + // UserProfileResponse의 from 메서드를 활용 + return UserProfileResponse.from(user, userProfileOptional.orElse(null)); + } + + public void updateUserProfile(Long userId, String newProfile) { + log.info("Updating profile for user {} to {}", userId, newProfile); // 플레이스홀더 사용 권장 (성능 및 가독성) + // ... + // 만약 여기서 예외를 던지면 @AfterThrowing 또는 @Around의 catch 블록이 동작하는지 확인할 수 있습니다. + // throw new RuntimeException("Test exception during update"); + } +} diff --git a/src/main/java/com/cMall/feedShop/user/application/service/UserSecurityService.java b/src/main/java/com/cMall/feedShop/user/application/service/UserSecurityService.java new file mode 100644 index 000000000..538348687 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/service/UserSecurityService.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.application.service; + +public class UserSecurityService { +} diff --git a/src/main/java/com/cMall/feedShop/user/application/service/UserService.java b/src/main/java/com/cMall/feedShop/user/application/service/UserService.java new file mode 100644 index 000000000..5524ba430 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/application/service/UserService.java @@ -0,0 +1,78 @@ +package com.cMall.feedShop.user.application.service; + +//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.AuthTokenResponse; +import com.cMall.feedShop.user.application.dto.response.UserResponse; +import com.cMall.feedShop.user.domain.model.User; +import com.cMall.feedShop.user.domain.enums.UserRole; +import com.cMall.feedShop.user.domain.enums.UserStatus; +import com.cMall.feedShop.user.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; // Lombok 임포트 +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// JWT 토큰 발급/검증을 위한 JwtProvider는 일단 주석 처리 (JWT 도입 전까지) +// import com.cMall.feedShop.user.application.jwt.JwtProvider; // 가상의 JwtProvider + +@Service +@Transactional +@RequiredArgsConstructor // final 필드를 인자로 받는 생성자를 자동 생성 +public class UserService { + + private final UserRepository userRepository; + // private final JwtProvider jwtProvider; + private final PasswordEncoder passwordEncoder; + + public UserResponse signUp(UserSignUpRequest request) { + // 1. 중복 체크 + if (userRepository.existsByLoginId(request.getLoginId())) { + throw new RuntimeException("이미 존재하는 사용자입니다."); + } + + String encodedPasswordFromRequest = request.getPassword(); + + // 3. 사용자 생성 및 저장 + User user = new User( + request.getLoginId(), + encodedPasswordFromRequest, // 이미 암호화된 비밀번호 사용 + request.getEmail(), + request.getPhone(), + UserRole.ROLE_USER + ); + userRepository.save(user); + + // 4. UserResponse로 변환해서 반환 + return UserResponse.from(user); + } + + +// public AuthTokenResponse login(UserLoginRequest request) { + // 1. 사용자 검증 +// User user = userRepository.findByUsername(request.getUsername()) +// .orElseThrow(() -> new RuntimeException("존재하지 않는 사용자입니다.")); + + // 2. 비밀번호 확인 + // Aspect에서 사용한 PasswordEncryptionService의 matches 메서드를 사용해야 함 + // 이를 위해 PasswordEncryptionService를 이 UserService에도 주입받아야 합니다. + // private final PasswordEncryptionService passwordEncryptionService; + // if (!passwordEncryptionService.matches(request.getPassword(), user.getPassword())) { + // throw new RuntimeException("비밀번호가 일치하지 않습니다."); + // } + // 혹은, 여기에서 Spring Security의 PasswordEncoder를 다시 주입받아 사용할 수도 있습니다. + // private final PasswordEncoder passwordEncoder; + // if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + // throw new RuntimeException("비밀번호가 일치하지 않습니다."); + // } + + + // 3. JWT 토큰 생성 (JWT 미도입 시 이 부분은 주석 처리 또는 제거) + // String accessToken = jwtProvider.createAccessToken(user.getUsername(), user.getRole().name()); + // String refreshToken = jwtProvider.createRefreshToken(user.getUsername()); + + // 4. 토큰 반환 (JWT 미도입 시 적절한 응답으로 변경) + // return new AuthTokenResponse(accessToken, refreshToken); +// throw new UnsupportedOperationException("JWT is not enabled yet for login."); // 임시 +// } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/user/domain/enums/DiscountType.java b/src/main/java/com/cMall/feedShop/user/domain/enums/DiscountType.java new file mode 100644 index 000000000..a870c17fd --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/enums/DiscountType.java @@ -0,0 +1,6 @@ +package com.cMall.feedShop.user.domain.enums; + +public enum DiscountType { + FIXED_DISCOUNT, + RATE_DISCOUNT +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/enums/UserCouponStatus.java b/src/main/java/com/cMall/feedShop/user/domain/enums/UserCouponStatus.java new file mode 100644 index 000000000..c7c24f8bb --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/enums/UserCouponStatus.java @@ -0,0 +1,7 @@ +package com.cMall.feedShop.user.domain.enums; + +public enum UserCouponStatus { + ACTIVE, // 활성 (사용 가능) + USED, // 사용됨 + EXPIRED // 만료됨 +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/enums/UserRole.java b/src/main/java/com/cMall/feedShop/user/domain/enums/UserRole.java new file mode 100644 index 000000000..ddfda0f69 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/enums/UserRole.java @@ -0,0 +1,7 @@ +package com.cMall.feedShop.user.domain.enums; + +public enum UserRole { + ROLE_ADMIN, + ROLE_SELLER, + ROLE_USER +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/enums/UserStatus.java b/src/main/java/com/cMall/feedShop/user/domain/enums/UserStatus.java new file mode 100644 index 000000000..47dd4b999 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/enums/UserStatus.java @@ -0,0 +1,9 @@ +package com.cMall.feedShop.user.domain.enums; + + +public enum UserStatus { + ACTIVE, + INACTIVE, + BLOCKED, + SUSPENDED +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/model/CouponDefinition.java b/src/main/java/com/cMall/feedShop/user/domain/model/CouponDefinition.java new file mode 100644 index 000000000..7b6178c5e --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/model/CouponDefinition.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.domain.model; + +public class CouponDefinition { +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/model/PointTransaction.java b/src/main/java/com/cMall/feedShop/user/domain/model/PointTransaction.java new file mode 100644 index 000000000..c2f508c7c --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/model/PointTransaction.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.domain.model; + +public class PointTransaction { +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/model/User.java b/src/main/java/com/cMall/feedShop/user/domain/model/User.java new file mode 100644 index 000000000..555919587 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/model/User.java @@ -0,0 +1,130 @@ +package com.cMall.feedShop.user.domain.model; + +import com.cMall.feedShop.user.domain.enums.UserRole; +import com.cMall.feedShop.user.domain.enums.UserStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; // <-- 추가 +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +public class User implements UserDetails { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinColumn(name="user_id") + private UserProfile userProfile; + + @Column(name = "login_id", unique = true, nullable = false, length = 100) + private String loginId; + + @Column(nullable = false, length = 255) + private String password; + + @Column(name = "password_changed_at") + private LocalDateTime passwordChangedAt; + + @Column(unique = true, nullable = false, length = 255) + private String email; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, columnDefinition = "ENUM('ACTIVE', 'INACTIVE', 'BLOCKED', 'DELETED') DEFAULT 'ACTIVE'") + private UserStatus status; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, columnDefinition = "ENUM('ROLE_USER', 'ROLE_ADMIN', 'ROLE_SELLER') DEFAULT 'ROLE_USER'") // ERD의 role + private UserRole role; + + @Column(nullable = false, length = 20) + private String phone; + + //(회원가입 시 사용) + public User(String loginId, String password, String email, String phone, UserRole role) { + this.loginId = loginId; + this.password = password; + this.email = email; + this.phone = phone; + this.role = role; + this.status = UserStatus.ACTIVE; // 기본 상태 활성화 + // @CreatedDate, @LastModifiedDate가 자동 처리하므로 생성자에서 초기화 제거 가능 + // this.createdAt = LocalDateTime.now(); + // this.updatedAt = LocalDateTime.now(); + this.passwordChangedAt = LocalDateTime.now(); // 초기 비밀번호 변경 시간 설정 + } + + // 비즈니스 메서드 + public void changePassword(String newPassword) { + // 도메인 규칙 검증 + } + + @Override + public String getUsername() { + return loginId; + } + + // UserDetails 인터페이스의 다른 메서드 구현 (중요!) + @Override + public Collection getAuthorities() { + // 사용자의 역할을 Spring Security의 권한(GrantedAuthority)으로 변환하여 반환합니다. + // UserRole.ROLE_USER -> new SimpleGrantedAuthority("ROLE_USER") + return List.of(new SimpleGrantedAuthority(this.role.name())); + } + + @Override + public boolean isAccountNonExpired() { + // 계정 만료 여부. 여기서는 항상 true를 반환하지만, + // 필요에 따라 만료 일자를 User 엔티티에 추가하고 비교할 수 있습니다. + return true; + } + + @Override + public boolean isAccountNonLocked() { + // 계정 잠금 여부. UserStatus를 활용하여 BLOCKED 상태일 경우 잠긴 것으로 간주할 수 있습니다. + return this.status != UserStatus.BLOCKED; + } + + @Override + public boolean isCredentialsNonExpired() { + // 비밀번호 만료 여부. passwordChangedAt을 활용하여 특정 기간이 지나면 만료되도록 할 수 있습니다. + // 여기서는 항상 true를 반환하지만, 실제 서비스에서는 보안 정책에 따라 구현해야 합니다. + return true; + } + + @Override + public boolean isEnabled() { + // 계정 활성화 여부. UserStatus가 ACTIVE일 때만 활성화된 것으로 간주합니다. + return this.status == UserStatus.ACTIVE; + } + + // canLogin() 메서드는 UserDetails의 isEnabled()와 역할이 중복되거나 유사할 수 있으므로 + // UserDetails의 isEnabled()를 사용하는 것을 권장합니다. + // public boolean canLogin() { + // return status == UserStatus.ACTIVE; + // } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/user/domain/model/UserAuth.java b/src/main/java/com/cMall/feedShop/user/domain/model/UserAuth.java new file mode 100644 index 000000000..ba8c136b5 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/model/UserAuth.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.domain.model; + +public class UserAuth { +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/model/UserBadge.java b/src/main/java/com/cMall/feedShop/user/domain/model/UserBadge.java new file mode 100644 index 000000000..1e2808e34 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/model/UserBadge.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.domain.model; + +public class UserBadge { +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/model/UserCoupon.java b/src/main/java/com/cMall/feedShop/user/domain/model/UserCoupon.java new file mode 100644 index 000000000..fa09ffeca --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/model/UserCoupon.java @@ -0,0 +1,52 @@ +package com.cMall.feedShop.user.domain.model; + +import com.cMall.feedShop.user.domain.enums.DiscountType; +import com.cMall.feedShop.user.domain.enums.UserCouponStatus; +import jakarta.persistence.*; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; + +@Entity +@Table +public class UserCoupon { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name="coupon_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="user_id", nullable=false) + private User user; + + @Column(name="coupon_code", nullable=false, unique=true) + private String couponCode; + + @Column(name="coupon_name", nullable = false) + private String couponName; + + @Enumerated(EnumType.STRING) + @Column(name="discount_type", nullable = false) + private DiscountType discountType; + + @Column(name="discount_value") + private Double discountValue; + + @Column(name="is_free_shiping", nullable = false) + private boolean isFreeShipping = false; + + @Enumerated(EnumType.STRING) + @Column(name="coupon_status", nullable = false) + private UserCouponStatus couponStatus = UserCouponStatus.ACTIVE; + + @CreatedDate + @Column(name="issued_at", nullable = false, updatable = false) + private LocalDateTime issuedAt; + + @Column(name="expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "used_at") + private LocalDateTime usedAt; +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/model/UserPoint.java b/src/main/java/com/cMall/feedShop/user/domain/model/UserPoint.java new file mode 100644 index 000000000..4ec620bf2 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/model/UserPoint.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.domain.model; + +public class UserPoint { +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/model/UserProfile.java b/src/main/java/com/cMall/feedShop/user/domain/model/UserProfile.java new file mode 100644 index 000000000..5079e64cd --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/model/UserProfile.java @@ -0,0 +1,25 @@ +package com.cMall.feedShop.user.domain.model; + +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Table(name = "users_profile") +@Getter +public class UserProfile { + + @Id + @Column(name="user_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @MapsId // User 엔티티의 PK를 UserProfile의 PK로 사용 (공유 기본 키 전략) + @JoinColumn(name = "user_id") + private User user; + + @Column(name="name") + private String name; + + @Column(name="nickname") + private String nickname; +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/model/UserSocialProvider.java b/src/main/java/com/cMall/feedShop/user/domain/model/UserSocialProvider.java new file mode 100644 index 000000000..69118824b --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/model/UserSocialProvider.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.domain.model; + +public class UserSocialProvider { +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/repository/CouponDefinitionRepository.java b/src/main/java/com/cMall/feedShop/user/domain/repository/CouponDefinitionRepository.java new file mode 100644 index 000000000..f861175a0 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/repository/CouponDefinitionRepository.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.domain.repository; + +public interface CouponDefinitionRepository { +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/repository/PointTransactionRepository.java b/src/main/java/com/cMall/feedShop/user/domain/repository/PointTransactionRepository.java new file mode 100644 index 000000000..70ff68362 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/repository/PointTransactionRepository.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.domain.repository; + +public interface PointTransactionRepository { +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/repository/UserAuthRepository.java b/src/main/java/com/cMall/feedShop/user/domain/repository/UserAuthRepository.java new file mode 100644 index 000000000..9580d9f06 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/repository/UserAuthRepository.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.domain.repository; + +public interface UserAuthRepository { +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/repository/UserBadgeRepository.java b/src/main/java/com/cMall/feedShop/user/domain/repository/UserBadgeRepository.java new file mode 100644 index 000000000..9824e4d72 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/repository/UserBadgeRepository.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.domain.repository; + +public interface UserBadgeRepository { +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/repository/UserCouponRepository.java b/src/main/java/com/cMall/feedShop/user/domain/repository/UserCouponRepository.java new file mode 100644 index 000000000..1840c786a --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/repository/UserCouponRepository.java @@ -0,0 +1,14 @@ +package com.cMall.feedShop.user.domain.repository; + +import com.cMall.feedShop.user.domain.enums.UserCouponStatus; +import com.cMall.feedShop.user.domain.model.User; +import com.cMall.feedShop.user.domain.model.UserCoupon; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface UserCouponRepository extends JpaRepository { + List findByUserAndCouponStatus(User user, UserCouponStatus couponStatus); + Optional findByCouponCode(String couponCode); +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/repository/UserPointRepository.java b/src/main/java/com/cMall/feedShop/user/domain/repository/UserPointRepository.java new file mode 100644 index 000000000..68741be1f --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/repository/UserPointRepository.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.domain.repository; + +public interface UserPointRepository { +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/repository/UserProfileRepository.java b/src/main/java/com/cMall/feedShop/user/domain/repository/UserProfileRepository.java new file mode 100644 index 000000000..7dc06f7de --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/repository/UserProfileRepository.java @@ -0,0 +1,11 @@ +package com.cMall.feedShop.user.domain.repository; +import com.cMall.feedShop.user.domain.model.User; +import com.cMall.feedShop.user.domain.model.UserProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserProfileRepository extends JpaRepository { + // User 엔티티를 이용하여 UserProfile을 찾는 쿼리 메서드 추가 + Optional findByUser(User user); +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/repository/UserRepository.java b/src/main/java/com/cMall/feedShop/user/domain/repository/UserRepository.java new file mode 100644 index 000000000..756cc01bd --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/repository/UserRepository.java @@ -0,0 +1,15 @@ +package com.cMall.feedShop.user.domain.repository; + +import com.cMall.feedShop.user.domain.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + boolean existsByLoginId(String loginId); + + Optional findByLoginId(String loginId); + + Optional findByEmail(String email); +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/repository/UserSocialProviderRepository.java b/src/main/java/com/cMall/feedShop/user/domain/repository/UserSocialProviderRepository.java new file mode 100644 index 000000000..5eeb4a3ba --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/repository/UserSocialProviderRepository.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.domain.repository; + +public interface UserSocialProviderRepository { +} diff --git a/src/main/java/com/cMall/feedShop/user/domain/service/PasswordEncryptionService.java b/src/main/java/com/cMall/feedShop/user/domain/service/PasswordEncryptionService.java new file mode 100644 index 000000000..d56a74c07 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/service/PasswordEncryptionService.java @@ -0,0 +1,6 @@ +package com.cMall.feedShop.user.domain.service; + +public interface PasswordEncryptionService { + String encrypt(String rawPassword); + boolean matches(String rawPassword, String encodedPassword); +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/user/domain/service/UserDomainService.java b/src/main/java/com/cMall/feedShop/user/domain/service/UserDomainService.java new file mode 100644 index 000000000..d91d9d212 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/domain/service/UserDomainService.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.domain.service; + +public class UserDomainService { +} diff --git a/src/main/java/com/cMall/feedShop/user/infrastructure/external/EmailService.java b/src/main/java/com/cMall/feedShop/user/infrastructure/external/EmailService.java new file mode 100644 index 000000000..897bf9f31 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/infrastructure/external/EmailService.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.infrastructure.external; + +public class EmailService { +} diff --git a/src/main/java/com/cMall/feedShop/user/infrastructure/external/SmsService.java b/src/main/java/com/cMall/feedShop/user/infrastructure/external/SmsService.java new file mode 100644 index 000000000..56415d68f --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/infrastructure/external/SmsService.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.infrastructure.external; + +public class SmsService { +} diff --git a/src/main/java/com/cMall/feedShop/user/infrastructure/repository/JpaUserAuthRepository.java b/src/main/java/com/cMall/feedShop/user/infrastructure/repository/JpaUserAuthRepository.java new file mode 100644 index 000000000..12f37209e --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/infrastructure/repository/JpaUserAuthRepository.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.infrastructure.repository; + +public interface JpaUserAuthRepository { +} diff --git a/src/main/java/com/cMall/feedShop/user/infrastructure/repository/JpaUserProfileRepository.java b/src/main/java/com/cMall/feedShop/user/infrastructure/repository/JpaUserProfileRepository.java new file mode 100644 index 000000000..9c69b5f14 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/infrastructure/repository/JpaUserProfileRepository.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.infrastructure.repository; + +public interface JpaUserProfileRepository { +} diff --git a/src/main/java/com/cMall/feedShop/user/infrastructure/repository/JpaUserRepository.java b/src/main/java/com/cMall/feedShop/user/infrastructure/repository/JpaUserRepository.java new file mode 100644 index 000000000..c9ebe5528 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/infrastructure/repository/JpaUserRepository.java @@ -0,0 +1,12 @@ +//package com.cMall.feedShop.user.infrastructure.repository; +// +//import com.cMall.feedShop.user.domain.User; +//import com.cMall.shopChat.user.domain.repository.UserRepository; +//import org.springframework.data.jpa.repository.JpaRepository; +//import org.springframework.stereotype.Repository; +// +//@Repository +//public interface JpaUserRepository extends JpaRepository, UserRepository { +//// Optional findByUsername(String username); +//// Optional findByEmail(String email); +//} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/user/infrastructure/security/JwtTokenProvider.java b/src/main/java/com/cMall/feedShop/user/infrastructure/security/JwtTokenProvider.java new file mode 100644 index 000000000..62157be27 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/infrastructure/security/JwtTokenProvider.java @@ -0,0 +1,90 @@ +package com.cMall.feedShop.user.infrastructure.security; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; // UserDetails import 유지 (generateRefreshToken 등에서 사용될 수 있음) +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +public class JwtTokenProvider { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-token-expiration-ms:3600000}") // 1시간 + private long accessTokenExpiration; + + @Value("${jwt.refresh-token-expiration-ms:1209600000}") // 2주 + private long refreshTokenExpiration; + + private Key key; + + @PostConstruct + public void init() { + // 시크릿 키는 최소 256비트 (HS256) 또는 512비트 (HS512) 이상을 권장합니다. + // 환경 변수나 설정 파일에서 가져오는 secretKey가 충분히 길고 복잡한지 확인하세요. + // 만약 짧다면, "your_super_secret_key_for_jwt_signing_which_should_be_at_least_256_bit" + // 와 같이 안전한 값을 사용해야 합니다. + this.key = Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + // AccessToken 생성: subject를 email로 설정하고 role을 클레임에 추가 + public String generateAccessToken(String email, String role) { // <-- 시그니처 변경: UserDetails 대신 email과 role 직접 받음 + Map claims = new HashMap<>(); + claims.put("role", role); // 클레임에 사용자 역할 추가 + + return Jwts.builder() + .setClaims(claims) // 클레임 설정 + .setSubject(email) // <-- 토큰의 주체(subject)를 email로 설정 + .setIssuedAt(new Date()) // 발행 시간 + .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpiration)) // 만료 시간 + .signWith(key, SignatureAlgorithm.HS256) // 서명 알고리즘과 시크릿 키 + .compact(); // 토큰 생성 + } + + public String generateRefreshToken(String username) { + // refresh token의 subject도 email로 할지 loginId로 할지 결정해야 합니다. + // 여기서는 기존대로 username (즉, loginId)을 사용합니다. + return Jwts.builder() + .setSubject(username) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpiration)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (SecurityException | MalformedJwtException e) { + log.warn("Invalid JWT signature: {}", e.getMessage()); // JWT 서명 문제 + } catch (ExpiredJwtException e) { + log.warn("Expired JWT token: {}", e.getMessage()); // JWT 만료 + } catch (UnsupportedJwtException e) { + log.warn("Unsupported JWT token: {}", e.getMessage()); // 지원되지 않는 JWT 형식 + } catch (IllegalArgumentException e) { + log.warn("JWT claims string is empty: {}", e.getMessage()); // JWT 클레임 문자열이 비어있음 + } + return false; + } + + // 토큰에서 email (subject)을 가져오는 메서드 + public String getEmailFromToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } +} diff --git a/src/main/java/com/cMall/feedShop/user/infrastructure/security/OAuth2SuccessHandler.java b/src/main/java/com/cMall/feedShop/user/infrastructure/security/OAuth2SuccessHandler.java new file mode 100644 index 000000000..1041a22c0 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/infrastructure/security/OAuth2SuccessHandler.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.infrastructure.security; + +public class OAuth2SuccessHandler { +} diff --git a/src/main/java/com/cMall/feedShop/user/infrastructure/security/RefreshTokenManager.java b/src/main/java/com/cMall/feedShop/user/infrastructure/security/RefreshTokenManager.java new file mode 100644 index 000000000..ceeed1e98 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/infrastructure/security/RefreshTokenManager.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.infrastructure.security; + +public class RefreshTokenManager { +} diff --git a/src/main/java/com/cMall/feedShop/user/infrastructure/service/BCryptPasswordEncryptionService.java b/src/main/java/com/cMall/feedShop/user/infrastructure/service/BCryptPasswordEncryptionService.java new file mode 100644 index 000000000..57d62f4e9 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/infrastructure/service/BCryptPasswordEncryptionService.java @@ -0,0 +1,28 @@ +package com.cMall.feedShop.user.infrastructure.service; + +import com.cMall.feedShop.user.domain.service.PasswordEncryptionService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BCryptPasswordEncryptionService implements PasswordEncryptionService { + private final PasswordEncoder passwordEncoder; + + @Override + public String encrypt(String rawPassword) { // 메서드 이름도 명확하게 + if (rawPassword == null || rawPassword.isEmpty()) { + return rawPassword; + } + return passwordEncoder.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + if (rawPassword == null || encodedPassword == null) { + return false; + } + return passwordEncoder.matches(rawPassword, encodedPassword); + } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/user/infrastructure/service/CustomUserDetailsService.java b/src/main/java/com/cMall/feedShop/user/infrastructure/service/CustomUserDetailsService.java new file mode 100644 index 000000000..09645f106 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/infrastructure/service/CustomUserDetailsService.java @@ -0,0 +1,52 @@ +package com.cMall.feedShop.user.infrastructure.service; // 또는 적절한 패키지 경로 + +import com.cMall.feedShop.user.domain.model.User; +import com.cMall.feedShop.user.domain.repository.UserRepository; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +// 스프링 컨테이너에 빈으로 등록되도록 @Service 어노테이션을 붙입니다. +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + // UserRepository를 주입받습니다. + public CustomUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + // 여기서 'username' 파라미터는 React에서 보낸 'email' 값입니다. + // 따라서, email로 사용자를 조회해야 합니다. + User user = userRepository.findByEmail(username) // <-- findByLoginId 대신 findByEmail 사용 + .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + username)); + + // User 엔티티가 UserDetails를 구현하고 있으므로, User 객체 자체를 반환할 수 있습니다. + // UserDetails 구현 메서드들이 User.java에 올바르게 구현되어 있는지 다시 확인해야 합니다. + // 특히 getAuthorities(), isEnabled() 등. + return user; + } + +// @Override +// public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { +// // 데이터베이스에서 loginId를 사용하여 User 엔티티를 조회합니다. +// User user = userRepository.findByLoginId(username) +// .orElseThrow(() -> new UsernameNotFoundException("User not found with loginId: " + username)); +// +// return org.springframework.security.core.userdetails.User.builder() +// .username(user.getLoginId()) +// .password(user.getPassword()) +// .authorities(user.getRole().name()) // UserRole ENUM의 이름을 문자열 권한으로 사용 +// .accountExpired(false) // 만료되지 않은 계정 +// .accountLocked(false) // 잠기지 않은 계정 +// .credentialsExpired(false) // 비밀번호 만료되지 않음 +// .disabled(user.getStatus() != com.cMall.feedShop.user.domain.enums.UserStatus.ACTIVE) // 활성 상태에 따라 계정 비활성화 여부 결정 +// .build(); +// } + +} \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/user/presentation/AdminUserController.java b/src/main/java/com/cMall/feedShop/user/presentation/AdminUserController.java new file mode 100644 index 000000000..1bebd959b --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/presentation/AdminUserController.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.presentation; + +public class AdminUserController { +} diff --git a/src/main/java/com/cMall/feedShop/user/presentation/SocialAuthController.java b/src/main/java/com/cMall/feedShop/user/presentation/SocialAuthController.java new file mode 100644 index 000000000..10cdfbe22 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/presentation/SocialAuthController.java @@ -0,0 +1,4 @@ +package com.cMall.feedShop.user.presentation; + +public class SocialAuthController { +} diff --git a/src/main/java/com/cMall/feedShop/user/presentation/UserAuthController.java b/src/main/java/com/cMall/feedShop/user/presentation/UserAuthController.java new file mode 100644 index 000000000..5ec95bca2 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/presentation/UserAuthController.java @@ -0,0 +1,43 @@ +package com.cMall.feedShop.user.presentation; // 현재 패키지 유지, 필요시 com.cMall.feedShop.auth.presentation으로 변경 권장 + +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.UserLoginResponse; +import com.cMall.feedShop.user.application.dto.response.UserResponse; +import com.cMall.feedShop.user.application.service.UserAuthService; +import com.cMall.feedShop.user.application.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; // Lombok 임포트 +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") // 인증 관련 엔드포인트 기본 경로 +@RequiredArgsConstructor // final 필드를 인자로 받는 생성자를 자동 생성 +public class UserAuthController { + + private final UserService userService; // 회원가입 등 기본적인 사용자 CRUD를 담당하는 서비스 + private final UserAuthService userAuthService; // 로그인 등 인증 관련 로직을 담당하는 서비스 + + // @RequiredArgsConstructor가 생성자를 자동으로 만들어주므로 수동 생성자 삭제 + // public AuthController(UserService userService, UserAuthService userAuthService) { + // this.userService = userService; + // this.userAuthService = userAuthService; + // } + + @PostMapping("/signup") // POST /api/auth/signup 요청 처리 + public ResponseEntity signUp(@Valid @RequestBody UserSignUpRequest request) { + // 회원가입은 UserService에 위임 (사용자 생성 로직) + return ResponseEntity.ok(userService.signUp(request)); + } + + @PostMapping("/login") // POST /api/auth/login 요청 처리 (React 코드와 일치) + public ResponseEntity login(@Valid @RequestBody UserLoginRequest request) { + // 로그인 인증은 AuthService에 위임 + return ResponseEntity.ok(userAuthService.login(request)); + } +} diff --git a/src/main/java/com/cMall/feedShop/user/presentation/UserController.java b/src/main/java/com/cMall/feedShop/user/presentation/UserController.java new file mode 100644 index 000000000..49a482439 --- /dev/null +++ b/src/main/java/com/cMall/feedShop/user/presentation/UserController.java @@ -0,0 +1,28 @@ +package com.cMall.feedShop.user.presentation; + +import com.cMall.feedShop.user.application.dto.response.UserProfileResponse; +import com.cMall.feedShop.user.application.service.UserProfileService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/users") // 사용자 리소스에 대한 기본 경로 +@RequiredArgsConstructor +public class UserController { + private final UserProfileService userProfileService; + // UserService 주입 제거 (signup이 AuthController로 이동했으므로) + + // 사용자 프로필을 조회하는 예시 메서드 + @GetMapping("/{userId}/profile") + public UserProfileResponse getUserProfile(@PathVariable Long userId) { + // userProfileService를 사용하여 실제 비즈니스 로직 호출 + UserProfileResponse response = userProfileService.getUserProfile(userId); + return response; + } + + // JWT 로그인 메서드도 있다면 여기에 추가 + // @PostMapping("/login") + // public AuthTokenResponse login(@RequestBody UserLoginRequest request) { + // return userService.login(request); + // } +} \ No newline at end of file diff --git a/src/main/java/com/cMall/shopChat/ShopChatApplication.java b/src/main/java/com/cMall/shopChat/ShopChatApplication.java deleted file mode 100644 index ab722d0cc..000000000 --- a/src/main/java/com/cMall/shopChat/ShopChatApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cMall.shopChat; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class ShopChatApplication { - - public static void main(String[] args) { - SpringApplication.run(ShopChatApplication.class, args); - } - -} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 000000000..a9d7895b4 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,53 @@ +spring.application.name=${SPRING_APPLICATION_NAME:feedshop} + +spring.datasource.url=jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.devtools.restart.enabled=true +spring.jpa.hibernate.ddl-auto=update +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect + +server.port=${SERVER_PORT:8443} +server.ssl.enabled=${SSL_ENABLED:true} +server.ssl.key-store=${SSL_KEY_STORE:classpath:keystore.p12} +server.ssl.key-store-password=${SSL_KEY_STORE_PASSWORD:pass123!!} +server.ssl.key-store-type=${SSL_KEY_STORE_TYPE:PKCS12} +server.ssl.key-alias=${SSL_KEY_ALIAS:springboot} +server.ssl.client-auth=none + +spring.mail.host=${MAIL_HOST} +spring.mail.port=${MAIL_PORT} +spring.mail.username=${MAIL_USERNAME} +spring.mail.password=${MAIL_PASSWORD} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +spring.mail.properties.mail.smtp.starttls.required=true + +logging.level.org.springframework=INFO +logging.level.com.cMall.feedShop=INFO +logging.level.org.springframework.security=INFO + +logging.level.root=INFO +logging.level.com.cMall.feedShop.common.aop=INFO + +logging.level.org.springframework.boot.web.embedded.tomcat=INFO +logging.level.org.apache.tomcat=INFO +logging.level.org.springframework.boot.autoconfigure.web.servlet.TomcatServletWebServerFactory=INFO + +logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] %logger{36} - %msg%n + +logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] %logger{36} - %msg%n + +logging.file.name=logs/shopping-mall.log + +logging.file.max-size=100MB + +logging.file.max-history=30 + +logging.file.total-size-cap=1GB + +management.endpoints.web.exposure.include=* \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 40f1da392..000000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,10 +0,0 @@ -spring.application.name=shopChat - -spring.datasource.url=jdbc:mysql://localhost:3306/shopchat?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 -spring.datasource.username=cmall -spring.datasource.password=pass -spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver - -spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=true -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect \ No newline at end of file diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 000000000..85e81efcf Binary files /dev/null and b/src/main/resources/static/favicon.ico differ diff --git a/src/test/java/com/cMall/feedShop/FeedShopApplicationTests.java b/src/test/java/com/cMall/feedShop/FeedShopApplicationTests.java new file mode 100644 index 000000000..5a6fdc070 --- /dev/null +++ b/src/test/java/com/cMall/feedShop/FeedShopApplicationTests.java @@ -0,0 +1,13 @@ +package com.cMall.feedShop; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest +class FeedShopApplicationTests { + @Test + void contextLoads() { + } +} diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewBlindRequestTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewBlindRequestTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewCreateRequestTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewCreateRequestTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewFilterRequestTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewFilterRequestTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewImageOrderRequestTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewImageOrderRequestTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewReportRequestTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewReportRequestTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewRequestTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewRequestTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewSearchRequestTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewSearchRequestTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewUpdateRequestTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/request/ReviewUpdateRequestTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ProductReviewSummaryResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ProductReviewSummaryResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewBlindResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewBlindResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewCountResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewCountResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewCreateResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewCreateResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewDetailResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewDetailResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewImageOrderResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewImageOrderResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewImageResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewImageResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewImageUploadResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewImageUploadResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewRatingResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewRatingResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewReportListResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewReportListResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewReportResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewReportResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewSearchResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewSearchResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewStatisticsResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewStatisticsResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewSummaryResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewSummaryResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewUpdateResponseTest.java b/src/test/java/com/cMall/feedShop/review/application/dto/response/ReviewUpdateResponseTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/exception/ReviewExceptionTest.java b/src/test/java/com/cMall/feedShop/review/application/exception/ReviewExceptionTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/service/ReviewImageServiceTest.java b/src/test/java/com/cMall/feedShop/review/application/service/ReviewImageServiceTest.java new file mode 100644 index 000000000..d270b52cb --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/application/service/ReviewImageServiceTest.java @@ -0,0 +1,101 @@ +/*package com.cMall.feedShop.review.application.service; + +import com.cMall.feedShop.review.application.ReviewImageService; +import com.cMall.feedShop.review.domain.entity.ReviewImage; +import com.cMall.feedShop.review.domain.repository.ReviewImageRepository; +import com.cMall.feedShop.review.application.dto.request.ReviewImageOrderRequest; +import com.cMall.feedShop.review.application.dto.response.ReviewImageUploadResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewImageOrderResponse; +import com.cMall.feedShop.review.application.exception.ReviewException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.multipart.MultipartFile; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; +import java.util.List; + +@ExtendWith(MockitoExtension.class) +class ReviewImageServiceTest { + + @Mock + private ReviewImageRepository reviewImageRepository; + + @InjectMocks + private ReviewImageService reviewImageService; + + @Test + @DisplayName("Given valid image file_When upload image_Then return upload response") + void givenValidImageFile_whenUploadImage_thenReturnUploadResponse() { + // given + Long reviewId = 1L; + MultipartFile mockFile = mock(MultipartFile.class); + when(mockFile.getOriginalFilename()).thenReturn("cushion.jpg"); + when(mockFile.getSize()).thenReturn(1024L); + when(mockFile.getContentType()).thenReturn("image/jpeg"); + + ReviewImage savedImage = ReviewImage.builder() + .imageId(1L) + .reviewId(reviewId) + .imageUrl("https://s3.example.com/cushion.jpg") + .originalName("cushion.jpg") + .displayOrder(1) + .build(); + + when(reviewImageRepository.save(any(ReviewImage.class))).thenReturn(savedImage); + + // when + ReviewImageUploadResponse response = reviewImageService.uploadImage(reviewId, mockFile); + + // then + assertNotNull(response); + assertEquals(1L, response.getImageId()); + assertEquals("https://s3.example.com/cushion.jpg", response.getImageUrl()); + verify(reviewImageRepository, times(1)).save(any(ReviewImage.class)); + } + + @Test + @DisplayName("Given invalid file type_When upload image_Then throw exception") + void givenInvalidFileType_whenUploadImage_thenThrowException() { + // given + Long reviewId = 1L; + MultipartFile mockFile = mock(MultipartFile.class); + when(mockFile.getContentType()).thenReturn("text/plain"); + + // when & then + assertThrows(ReviewException.class, () -> { + reviewImageService.uploadImage(reviewId, mockFile); + }); + } + + @Test + @DisplayName("Given order request_When update image order_Then return order response") + void givenOrderRequest_whenUpdateImageOrder_thenReturnOrderResponse() { + // given + Long reviewId = 1L; + ReviewImageOrderRequest orderRequest = ReviewImageOrderRequest.builder() + .imageOrders(List.of( + ReviewImageOrderRequest.ImageOrder.builder().imageId(1L).displayOrder(2).build(), + ReviewImageOrderRequest.ImageOrder.builder().imageId(2L).displayOrder(1).build() + )) + .build(); + + List images = List.of( + ReviewImage.builder().id(1L).reviewId(reviewId).displayOrder(2).build(), + ReviewImage.builder().id(2L).reviewId(reviewId).displayOrder(1).build() + ); + + when(reviewImageRepository.findByReviewIdOrderByDisplayOrder(reviewId)).thenReturn(images); + + // when + ReviewImageOrderResponse response = reviewImageService.updateImageOrder(reviewId, orderRequest); + + // then + assertNotNull(response); + assertEquals(2, response.getUpdatedImages().size()); + verify(reviewImageRepository, times(1)).saveAll(any()); + } +}*/ \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/application/service/ReviewReportServiceTest.java b/src/test/java/com/cMall/feedShop/review/application/service/ReviewReportServiceTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/application/service/ReviewServiceTest.java b/src/test/java/com/cMall/feedShop/review/application/service/ReviewServiceTest.java new file mode 100644 index 000000000..66af29d94 --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/application/service/ReviewServiceTest.java @@ -0,0 +1,227 @@ +package com.cMall.feedShop.review.application.service; + +import com.cMall.feedShop.review.application.ReviewService; +import com.cMall.feedShop.review.domain.entity.Review; +import com.cMall.feedShop.review.domain.entity.ReviewStatus; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.Stability; +import com.cMall.feedShop.review.domain.repository.ReviewRepository; +import com.cMall.feedShop.review.application.dto.request.ReviewCreateRequest; +import com.cMall.feedShop.review.application.dto.request.ReviewUpdateRequest; +import com.cMall.feedShop.review.application.dto.response.ReviewCreateResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewDetailResponse; +import com.cMall.feedShop.review.application.exception.ReviewException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; +import java.util.Optional; +import java.util.List; + +@ExtendWith(MockitoExtension.class) +public class ReviewServiceTest { + + @Mock + private ReviewRepository reviewRepository; + + @InjectMocks + private ReviewService reviewService; + + private ReviewCreateRequest createRequest; + private Review review; + + @BeforeEach + void setUp() { + createRequest = ReviewCreateRequest.builder() + .content("정말 편한 신발입니다. 하루 종일 신고 다녀도 발이 전혀 아프지 않아요") + .rating(5) + .userId(1L) + .productId(1L) + .sizeFit(SizeFit.PERFECT) // 딱 맞음 + .cushioning(Cushion.VERY_SOFT) // 매우 부드러움 + .stability(Stability.VERY_STABLE) // 매우 안정적 + .build(); + + review = Review.builder() + .reviewId(1L) + .content("정말 편한 신발입니다. 하루 종일 신고 다녀도 발이 전혀 아프지 않아요") + .rating(5) + .userId(1L) + .productId(1L) + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .status(ReviewStatus.ACTIVE) + .build(); + } + + // RE-01: 신발 리뷰 작성 기능 테스트 (5단계 평가) + @Test + @DisplayName("Given 5-level shoe characteristics_When create review_Then return detailed response") + void given5LevelShoeCharacteristics_whenCreateReview_thenReturnDetailedResponse() { + // given + when(reviewRepository.save(any(Review.class))).thenReturn(review); + + // when + ReviewCreateResponse response = reviewService.createReview(createRequest); + + // then + assertNotNull(response); + assertEquals(1L, response.getReviewId()); + assertEquals(SizeFit.PERFECT, response.getSizeFit()); + assertEquals(Cushion.VERY_SOFT, response.getCushioning()); + assertEquals(Stability.VERY_STABLE, response.getStability()); + verify(reviewRepository, times(1)).save(any(Review.class)); + } + + @Test + @DisplayName("Given extreme negative characteristics_When create review_Then handle all extreme values") + void givenExtremeNegativeCharacteristics_whenCreateReview_thenHandleAllExtremeValues() { + // given - 최악의 신발 리뷰 + ReviewCreateRequest extremeRequest = ReviewCreateRequest.builder() + .content("정말 최악의 신발입니다. 사이즈도 안 맞고 쿠션도 없고 발목도 불안정해요") + .rating(1) + .userId(1L) + .productId(2L) + .sizeFit(SizeFit.VERY_SMALL) // 매우 작음 + .cushioning(Cushion.VERY_FIRM) // 매우 단단함 + .stability(Stability.VERY_UNSTABLE) // 매우 불안정 + .build(); + + Review extremeReview = Review.builder() + .reviewId(2L) + .content(extremeRequest.getContent()) + .rating(1) + .userId(1L) + .productId(2L) + .sizeFit(SizeFit.VERY_SMALL) + .cushioning(Cushion.VERY_FIRM) + .stability(Stability.VERY_UNSTABLE) + .status(ReviewStatus.ACTIVE) + .build(); + + when(reviewRepository.save(any(Review.class))).thenReturn(extremeReview); + + // when + ReviewCreateResponse response = reviewService.createReview(extremeRequest); + + // then + assertNotNull(response); + assertEquals(1, response.getRating()); + assertEquals(SizeFit.VERY_SMALL, response.getSizeFit()); + assertEquals(Cushion.VERY_FIRM, response.getCushioning()); + assertEquals(Stability.VERY_UNSTABLE, response.getStability()); + assertTrue(response.getContent().contains("최악의 신발")); + } + + // RE-02: 5단계 필터링으로 리뷰 목록 조회 + @Test + @DisplayName("Given very big size filter_When get filtered reviews_Then return matching reviews") + void givenVeryBigSizeFilter_whenGetFilteredReviews_thenReturnMatchingReviews() { + // given + Long productId = 1L; + SizeFit targetSizeFit = SizeFit.VERY_BIG; // 매우 큰 사이즈만 필터링 + + Review bigSizeReview = Review.builder() + .reviewId(1L) + .content("사이즈가 매우 커서 발이 헐렁거려요") + .sizeFit(SizeFit.VERY_BIG) + .cushioning(Cushion.NORMAL) + .stability(Stability.NORMAL) + .status(ReviewStatus.ACTIVE) + .build(); + + List reviews = List.of(bigSizeReview); + when(reviewRepository.findByProductIdAndSizeFitAndStatus(productId, targetSizeFit, ReviewStatus.ACTIVE)) + .thenReturn(reviews); + + // when + List responses = reviewService.getReviewsBySizeFit(productId, targetSizeFit); + + // then + assertEquals(1, responses.size()); + assertEquals(SizeFit.VERY_BIG, responses.get(0).getSizeFit()); + assertTrue(responses.get(0).getContent().contains("매우 커서")); + } + + @Test + @DisplayName("Given very soft cushioning filter_When get reviews_Then return ultra comfort reviews") + void givenVerySoftCushioningFilter_whenGetReviews_thenReturnUltraComfortReviews() { + // given + Long productId = 1L; + Cushion targetCushioning = Cushion.VERY_SOFT; + + Review ultraSoftReview = Review.builder() + .reviewId(1L) + .content("쿠션이 매우 부드러워서 구름 위를 걷는 느낌") + .cushioning(Cushion.VERY_SOFT) + .sizeFit(SizeFit.PERFECT) + .stability(Stability.NORMAL) + .status(ReviewStatus.ACTIVE) + .build(); + + List reviews = List.of(ultraSoftReview); + when(reviewRepository.findByProductIdAndCushioningAndStatus(productId, targetCushioning, ReviewStatus.ACTIVE)) + .thenReturn(reviews); + + // when + List responses = reviewService.getReviewsByCushioning(productId, targetCushioning); + + // then + assertEquals(1, responses.size()); + assertEquals(Cushion.VERY_SOFT, responses.get(0).getCushioning()); + assertTrue(responses.get(0).getContent().contains("구름 위를 걷는")); + } + + // RE-03: 리뷰 상세 조회 + @Test + @DisplayName("Given existing review id_When get review detail_Then return 5-level characteristics") + void givenExistingReviewId_whenGetReviewDetail_thenReturn5LevelCharacteristics() { + // given + Long reviewId = 1L; + when(reviewRepository.findById(reviewId)).thenReturn(Optional.of(review)); + + // when + ReviewDetailResponse response = reviewService.getReviewDetail(reviewId); + + // then + assertNotNull(response); + assertEquals(reviewId, response.getReviewId()); + assertEquals(SizeFit.PERFECT, response.getSizeFit()); + assertEquals(Cushion.VERY_SOFT, response.getCushioning()); + assertEquals(Stability.VERY_STABLE, response.getStability()); + } + + // RE-04: 시간 경과에 따른 특성 변화 업데이트 + @Test + @DisplayName("Given wearing time effect_When update review characteristics_Then reflect changes") + void givenWearingTimeEffect_whenUpdateReviewCharacteristics_thenReflectChanges() { + // given + Long reviewId = 1L; + ReviewUpdateRequest updateRequest = ReviewUpdateRequest.builder() + .content("한 달 신어보니 처음과 달라졌어요. 늘어나서 커지고 쿠션도 주저앉았네요") + .rating(3) + .sizeFit(SizeFit.BIG) // 늘어나서 커짐 + .cushioning(Cushion.FIRM) // 쿠션이 주저앉아서 단단해짐 + .stability(Stability.UNSTABLE) // 안정성도 떨어짐 + .build(); + + when(reviewRepository.findById(reviewId)).thenReturn(Optional.of(review)); + when(reviewRepository.save(any(Review.class))).thenReturn(review); + + // when + assertDoesNotThrow(() -> { + reviewService.updateReview(reviewId, updateRequest); + }); + + // then + verify(reviewRepository, times(1)).findById(reviewId); + verify(reviewRepository, times(1)).save(any(Review.class)); + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/application/service/ReviewStatisticsServiceTest.java b/src/test/java/com/cMall/feedShop/review/application/service/ReviewStatisticsServiceTest.java new file mode 100644 index 000000000..6a1423f0f --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/application/service/ReviewStatisticsServiceTest.java @@ -0,0 +1,188 @@ +package com.cMall.feedShop.review.application.service; + +import com.cMall.feedShop.review.application.ReviewStatisticsService; +import com.cMall.feedShop.review.domain.repository.ReviewRepository; +import com.cMall.feedShop.review.domain.entity.ReviewStatus; +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Stability; +import com.cMall.feedShop.review.application.dto.response.ReviewStatisticsResponse; +import com.cMall.feedShop.review.application.dto.response.ProductReviewSummaryResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewSummaryResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.List; +import java.util.ArrayList; + +@ExtendWith(MockitoExtension.class) +class ReviewStatisticsServiceTest { + + @Mock + private ReviewRepository reviewRepository; + + @InjectMocks + private ReviewStatisticsService reviewStatisticsService; + + @Test + @DisplayName("Given product id_When get statistics_Then return statistics response") + void givenProductId_whenGetStatistics_thenReturnStatisticsResponse() { + // given + Long productId = 1L; + + // Repository 메서드 mocking + when(reviewRepository.findAverageRatingByProductId(productId)).thenReturn(4.5); + when(reviewRepository.countByProductIdAndStatus(productId, ReviewStatus.ACTIVE)).thenReturn(10L); + + // 평점별 분포 mocking + when(reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 5)).thenReturn(6L); + when(reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 4)).thenReturn(3L); + when(reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 3)).thenReturn(1L); + when(reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 2)).thenReturn(0L); + when(reviewRepository.countByProductIdAndStatusAndRating(productId, ReviewStatus.ACTIVE, 1)).thenReturn(0L); + + // when + ReviewStatisticsResponse response = reviewStatisticsService.getProductStatistics(productId); + + // then + assertNotNull(response); + assertEquals(productId, response.getProductId()); + assertEquals(4.5, response.getAverageRating()); + assertEquals(10L, response.getTotalReviews()); + assertEquals(6L, response.getRatingDistribution().get(5)); + verify(reviewRepository, times(1)).findAverageRatingByProductId(productId); + verify(reviewRepository, times(1)).countByProductIdAndStatus(productId, ReviewStatus.ACTIVE); + } + + @Test + @DisplayName("Given product id_When get product summary_Then return summary response") + void givenProductId_whenGetProductSummary_thenReturnSummaryResponse() { + // given + Long productId = 1L; + + // 최근 리뷰 목록 생성 + List recentReviews = List.of( + ReviewSummaryResponse.builder() + .reviewId(1L) + .userId(1L) + .productId(productId) + .reviewTitle("최고예요!") + .content("정말 좋은 상품입니다") + .rating(5) + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .createdAt(LocalDateTime.now()) + .images(new ArrayList<>()) + .build(), + ReviewSummaryResponse.builder() + .reviewId(2L) + .userId(2L) + .productId(productId) + .reviewTitle("편해요") + .content("편하고 좋습니다") + .rating(4) + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.SOFT) + .stability(Stability.STABLE) + .createdAt(LocalDateTime.now()) + .images(new ArrayList<>()) + .build() + ); + + // Repository mocking + when(reviewRepository.findAverageRatingByProductId(productId)).thenReturn(4.2); + when(reviewRepository.countByProductIdAndStatus(productId, ReviewStatus.ACTIVE)).thenReturn(25L); + + // when + ProductReviewSummaryResponse response = reviewStatisticsService.getProductReviewSummary(productId); + + // then + assertNotNull(response); + assertEquals(productId, response.getProductId()); + assertEquals(4.2, response.getAverageRating()); + assertEquals(25L, response.getTotalReviews()); + verify(reviewRepository, times(1)).findAverageRatingByProductId(productId); + verify(reviewRepository, times(1)).countByProductIdAndStatus(productId, ReviewStatus.ACTIVE); + } + + @Test + @DisplayName("Given cushioning type_When get average rating_Then return calculated average") + void givenCushioningType_whenGetAverageRating_thenReturnCalculatedAverage() { + // given + Cushion cushioningType = Cushion.VERY_SOFT; + Double expectedRating = 4.7; + + when(reviewRepository.findAverageRatingByCushioning(cushioningType, ReviewStatus.ACTIVE)) + .thenReturn(expectedRating); + + // when + Double actualRating = reviewStatisticsService.getAverageRatingByCushioning(cushioningType); + + // then + assertEquals(expectedRating, actualRating); + verify(reviewRepository, times(1)) + .findAverageRatingByCushioning(cushioningType, ReviewStatus.ACTIVE); + } + + @Test + @DisplayName("Given size fit type_When get average rating_Then return calculated average") + void givenSizeFitType_whenGetAverageRating_thenReturnCalculatedAverage() { + // given + SizeFit sizeFitType = SizeFit.PERFECT; + Double expectedRating = 4.5; + + when(reviewRepository.findAverageRatingBySizeFit(sizeFitType, ReviewStatus.ACTIVE)) + .thenReturn(expectedRating); + + // when + Double actualRating = reviewStatisticsService.getAverageRatingBySizeFit(sizeFitType); + + // then + assertEquals(expectedRating, actualRating); + verify(reviewRepository, times(1)) + .findAverageRatingBySizeFit(sizeFitType, ReviewStatus.ACTIVE); + } + + @Test + @DisplayName("Given stability type_When get average rating_Then return calculated average") + void givenStabilityType_whenGetAverageRating_thenReturnCalculatedAverage() { + // given + Stability stabilityType = Stability.VERY_STABLE; + Double expectedRating = 4.8; + + when(reviewRepository.findAverageRatingByStability(stabilityType, ReviewStatus.ACTIVE)) + .thenReturn(expectedRating); + + // when + Double actualRating = reviewStatisticsService.getAverageRatingByStability(stabilityType); + + // then + assertEquals(expectedRating, actualRating); + verify(reviewRepository, times(1)) + .findAverageRatingByStability(stabilityType, ReviewStatus.ACTIVE); + } + + @Test + @DisplayName("Given invalid product id_When get statistics_Then throw exception") + void givenInvalidProductId_whenGetStatistics_thenThrowException() { + // given + Long invalidProductId = 999L; + + when(reviewRepository.findAverageRatingByProductId(invalidProductId)).thenReturn(null); + when(reviewRepository.countByProductIdAndStatus(invalidProductId, ReviewStatus.ACTIVE)).thenReturn(0L); + + // when & then + assertThrows(IllegalArgumentException.class, () -> { + reviewStatisticsService.getProductStatistics(invalidProductId); + }); + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/base/BaseControllerTest.java b/src/test/java/com/cMall/feedShop/review/base/BaseControllerTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/base/BaseIntegrationTest.java b/src/test/java/com/cMall/feedShop/review/base/BaseIntegrationTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/base/BaseRepositoryTest.java b/src/test/java/com/cMall/feedShop/review/base/BaseRepositoryTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/base/BaseServiceTest.java b/src/test/java/com/cMall/feedShop/review/base/BaseServiceTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/base/TestConfig.java b/src/test/java/com/cMall/feedShop/review/base/TestConfig.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/domain/entity/CushionTest.java b/src/test/java/com/cMall/feedShop/review/domain/entity/CushionTest.java new file mode 100644 index 000000000..4657b6e2b --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/domain/entity/CushionTest.java @@ -0,0 +1,71 @@ +package com.cMall.feedShop.review.domain.entity; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import static org.junit.jupiter.api.Assertions.*; + +class CushionTest { + + @Test + @DisplayName("Given 5-level cushioning values_When get all values_Then return complete enum set") + void given5LevelCushioningValues_whenGetAllValues_thenReturnCompleteEnumSet() { + // given & when + Cushion[] allCushions = Cushion.values(); + + // then + assertEquals(5, allCushions.length); // 정확히 5개 + + // 신발 쿠션감 5단계가 모두 존재하는지 확인 + boolean hasVerySoft = false, hasSoft = false, hasNormal = false, + hasFirm = false, hasVeryFirm = false; + + for (Cushion cushion : allCushions) { + switch (cushion) { + case VERY_SOFT: hasVerySoft = true; break; // 매우 부드러움 + case SOFT: hasSoft = true; break; // 부드러움 + case NORMAL: hasNormal = true; break; // 보통 + case FIRM: hasFirm = true; break; // 단단함 + case VERY_FIRM: hasVeryFirm = true; break; // 매우 단단함 + } + } + + assertTrue(hasVerySoft && hasSoft && hasNormal && hasFirm && hasVeryFirm); + } + + @Test + @DisplayName("Given cushioning comparison_When check comfort level_Then order correctly") + void givenCushioningComparison_whenCheckComfortLevel_thenOrderCorrectly() { + // given & when & then + // 신발 쿠션감 순서: 매우 부드러움 > 부드러움 > 보통 > 단단함 > 매우 단단함 + assertTrue(Cushion.VERY_SOFT.ordinal() < Cushion.SOFT.ordinal()); + assertTrue(Cushion.SOFT.ordinal() < Cushion.NORMAL.ordinal()); + assertTrue(Cushion.NORMAL.ordinal() < Cushion.FIRM.ordinal()); + assertTrue(Cushion.FIRM.ordinal() < Cushion.VERY_FIRM.ordinal()); + } + + @Test + @DisplayName("Given cushioning extremes_When evaluate walking experience_Then return appropriate description") + void givenCushioningExtremes_whenEvaluateWalkingExperience_thenReturnAppropriateDescription() { + // given & when & then + assertEquals("VERY_SOFT", Cushion.VERY_SOFT.name()); // 구름 위를 걷는 느낌 + assertEquals("NORMAL", Cushion.NORMAL.name()); // 일반적인 쿠션감 + assertEquals("VERY_FIRM", Cushion.VERY_FIRM.name()); // 바닥 그대로 느껴짐 + } + + @Test + @DisplayName("Given cushioning preference by activity_When choose cushion level_Then match activity needs") + void givenCushioningPreferenceByActivity_whenChooseCushionLevel_thenMatchActivityNeeds() { + // given & when & then + // 일상 걷기: 부드러운 쿠션 선호 + Cushion casualWalking = Cushion.SOFT; + assertEquals(Cushion.SOFT, casualWalking); + + // 런닝: 적당한 쿠션 선호 + Cushion running = Cushion.NORMAL; + assertEquals(Cushion.NORMAL, running); + + // 농구/운동: 단단한 쿠션 선호 (반응성) + Cushion sports = Cushion.FIRM; + assertEquals(Cushion.FIRM, sports); + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/domain/entity/ReviewImageTest.java b/src/test/java/com/cMall/feedShop/review/domain/entity/ReviewImageTest.java new file mode 100644 index 000000000..e93c3ceed --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/domain/entity/ReviewImageTest.java @@ -0,0 +1,100 @@ +package com.cMall.feedShop.review.domain.entity; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import static org.junit.jupiter.api.Assertions.*; + +class ReviewImageTest { + + @Test + @DisplayName("Given image info_When create ReviewImage_Then all fields are set correctly") + void givenImageInfo_whenCreateReviewImage_thenAllFieldsAreSetCorrectly() { + // given + Long reviewId = 1L; + String imageUrl = "https://example.com/review-image.jpg"; + Integer imageOrder = 1; + + // when + ReviewImage reviewImage = ReviewImage.builder() + .reviewId(reviewId) + .imageUrl(imageUrl) + .imageOrder(imageOrder) + .build(); + + // then + assertNotNull(reviewImage); + assertEquals(reviewId, reviewImage.getReviewId()); + assertEquals(imageUrl, reviewImage.getImageUrl()); + assertEquals(imageOrder, reviewImage.getImageOrder()); + } + + @Test + @DisplayName("Given multiple images_When create with different orders_Then orders are set correctly") + void givenMultipleImages_whenCreateWithDifferentOrders_thenOrdersAreSetCorrectly() { + // given + Long reviewId = 1L; + + // when + ReviewImage firstImage = ReviewImage.builder() + .reviewId(reviewId) + .imageUrl("https://example.com/image1.jpg") + .imageOrder(1) + .build(); + + ReviewImage secondImage = ReviewImage.builder() + .reviewId(reviewId) + .imageUrl("https://example.com/image2.jpg") + .imageOrder(2) + .build(); + + ReviewImage thirdImage = ReviewImage.builder() + .reviewId(reviewId) + .imageUrl("https://example.com/image3.jpg") + .imageOrder(3) + .build(); + + // then + assertEquals(1, firstImage.getImageOrder()); + assertEquals(2, secondImage.getImageOrder()); + assertEquals(3, thirdImage.getImageOrder()); + + // 모든 이미지가 같은 리뷰에 속하는지 확인 + assertEquals(reviewId, firstImage.getReviewId()); + assertEquals(reviewId, secondImage.getReviewId()); + assertEquals(reviewId, thirdImage.getReviewId()); + } + + @Test + @DisplayName("Given valid image data_When create ReviewImage_Then no exception is thrown") + void givenValidImageData_whenCreateReviewImage_thenNoExceptionIsThrown() { + // given & when & then + assertDoesNotThrow(() -> { + ReviewImage reviewImage = ReviewImage.builder() + .reviewId(1L) + .imageUrl("https://cdn.example.com/shoe-review.png") + .imageOrder(1) + .build(); + + assertNotNull(reviewImage.getReviewId()); + assertNotNull(reviewImage.getImageUrl()); + assertNotNull(reviewImage.getImageOrder()); + }); + } + + @Test + @DisplayName("Given image order zero_When create ReviewImage_Then order is set to zero") + void givenImageOrderZero_whenCreateReviewImage_thenOrderIsSetToZero() { + // given + Integer imageOrder = 0; + + // when + ReviewImage reviewImage = ReviewImage.builder() + .reviewId(1L) + .imageUrl("https://example.com/test.jpg") + .imageOrder(imageOrder) + .build(); + + // then + assertEquals(0, reviewImage.getImageOrder()); + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/domain/entity/ReviewStatusTest.java b/src/test/java/com/cMall/feedShop/review/domain/entity/ReviewStatusTest.java new file mode 100644 index 000000000..77578839d --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/domain/entity/ReviewStatusTest.java @@ -0,0 +1,56 @@ +/*package com.cMall.feedShop.review.domain.entity; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import static org.junit.jupiter.api.Assertions.*; + +class ReviewStatusTest { + + @Test + @DisplayName("Given review status values_When get all values_Then return complete enum set") + void givenReviewStatusValues_whenGetAllValues_thenReturnCompleteEnumSet() { + // given & when + ReviewStatus[] allStatuses = ReviewStatus.values(); + + // then + assertTrue(allStatuses.length >= 3); // 최소 ACTIVE, BLIND, DELETED + + // 리뷰 상태들이 존재하는지 확인 + boolean hasActive = false, hasBlind = false, hasDeleted = false; + for (ReviewStatus status : allStatuses) { + switch (status) { + case ACTIVE: hasActive = true; break; // 활성 상태 + case BLIND: hasBlind = true; break; // 블라인드 처리 + case DELETED: hasDeleted = true; break; // 삭제됨 + } + } + + assertTrue(hasActive && hasBlind && hasDeleted); + } + + @Test + @DisplayName("Given review status_When check visibility_Then return correct state") + void givenReviewStatus_whenCheckVisibility_thenReturnCorrectState() { + // given & when & then + assertTrue(ReviewStatus.ACTIVE.isVisible()); // 활성 상태는 보임 + assertFalse(ReviewStatus.BLIND.isVisible()); // 블라인드는 안 보임 + assertFalse(ReviewStatus.DELETED.isVisible()); // 삭제는 안 보임 + } + + @Test + @DisplayName("Given review moderation_When change status_Then handle workflow correctly") + void givenReviewModeration_whenChangeStatus_thenHandleWorkflowCorrectly() { + // given + ReviewStatus initialStatus = ReviewStatus.ACTIVE; + + // when & then - 신고로 인한 블라인드 처리 + ReviewStatus blindStatus = ReviewStatus.BLIND; + assertNotEquals(initialStatus, blindStatus); + assertFalse(blindStatus.isVisible()); + + // when & then - 사용자 요청으로 삭제 + ReviewStatus deletedStatus = ReviewStatus.DELETED; + assertNotEquals(initialStatus, deletedStatus); + assertFalse(deletedStatus.isVisible()); + } +}*/ \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/domain/entity/ReviewTest.java b/src/test/java/com/cMall/feedShop/review/domain/entity/ReviewTest.java new file mode 100644 index 000000000..bbdac27e5 --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/domain/entity/ReviewTest.java @@ -0,0 +1,166 @@ +package com.cMall.feedShop.review.domain.entity; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import static org.junit.jupiter.api.Assertions.*; + +class ReviewTest { + + @Test + @DisplayName("Given valid shoe review data with 5-level enums_When create review_Then success") + void givenValidShoeReviewDataWith5LevelEnums_whenCreateReview_thenSuccess() { + // given + String content = "정말 편한 신발입니다. 장시간 착용해도 발이 전혀 아프지 않아요"; + int rating = 5; + Long userId = 1L; + SizeFit sizeFit = SizeFit.PERFECT; // 딱 맞음 + Cushion cushioning = Cushion.VERY_SOFT; // 매우 부드러움 + Stability stability = Stability.VERY_STABLE; // 매우 안정적 + + // when + Review review = Review.builder() + .content(content) + .rating(rating) + .userId(userId) + .sizeFit(sizeFit) + .cushioning(cushioning) + .stability(stability) + .status(ReviewStatus.ACTIVE) + .build(); + + // then + assertNotNull(review); + assertEquals(content, review.getContent()); + assertEquals(rating, review.getRating()); + assertEquals(userId, review.getUserId()); + assertEquals(sizeFit, review.getSizeFit()); + assertEquals(cushioning, review.getCushioning()); + assertEquals(stability, review.getStability()); + assertEquals(ReviewStatus.ACTIVE, review.getStatus()); + } + + @Test + @DisplayName("Given extreme size fit scenarios_When create review_Then handle all 5 levels") + void givenExtremeSizeFitScenarios_whenCreateReview_thenHandleAll5Levels() { + // given - 매우 작음 + Review verySmallReview = Review.builder() + .content("사이즈가 매우 작아요. 발가락이 심하게 눌려요") + .rating(2) + .userId(1L) + .sizeFit(SizeFit.VERY_SMALL) + .cushioning(Cushion.NORMAL) + .stability(Stability.NORMAL) + .status(ReviewStatus.ACTIVE) + .build(); + + // given - 매우 큼 + Review veryBigReview = Review.builder() + .content("사이즈가 매우 커요. 발이 신발 안에서 헐렁거려요") + .rating(2) + .userId(2L) + .sizeFit(SizeFit.VERY_BIG) + .cushioning(Cushion.NORMAL) + .stability(Stability.NORMAL) + .status(ReviewStatus.ACTIVE) + .build(); + + // when & then + assertEquals(SizeFit.VERY_SMALL, verySmallReview.getSizeFit()); + assertEquals(SizeFit.VERY_BIG, veryBigReview.getSizeFit()); + assertTrue(verySmallReview.getContent().contains("매우 작아요")); + assertTrue(veryBigReview.getContent().contains("매우 커요")); + } + + @Test + @DisplayName("Given extreme cushioning levels_When create review_Then handle all 5 levels") + void givenExtremeCushioningLevels_whenCreateReview_thenHandleAll5Levels() { + // given - 매우 부드러움 + Review verySoftReview = Review.builder() + .content("쿠션이 매우 부드러워서 구름 위를 걷는 느낌이에요") + .rating(5) + .userId(1L) + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.NORMAL) + .status(ReviewStatus.ACTIVE) + .build(); + + // given - 매우 단단함 + Review veryFirmReview = Review.builder() + .content("쿠션이 매우 단단해서 바닥을 그대로 느끼는 느낌") + .rating(2) + .userId(2L) + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_FIRM) + .stability(Stability.NORMAL) + .status(ReviewStatus.ACTIVE) + .build(); + + // when & then + assertEquals(Cushion.VERY_SOFT, verySoftReview.getCushioning()); + assertEquals(Cushion.VERY_FIRM, veryFirmReview.getCushioning()); + assertTrue(verySoftReview.getContent().contains("매우 부드러워서")); + assertTrue(veryFirmReview.getContent().contains("매우 단단해서")); + } + + @Test + @DisplayName("Given extreme stability levels_When create review_Then handle all 5 levels") + void givenExtremeStabilityLevels_whenCreateReview_thenHandleAll5Levels() { + // given - 매우 불안정 + Review veryUnstableReview = Review.builder() + .content("발목이 매우 불안정해서 조금만 걸어도 삐끗할 것 같아요") + .rating(1) + .userId(1L) + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.NORMAL) + .stability(Stability.VERY_UNSTABLE) + .status(ReviewStatus.ACTIVE) + .build(); + + // given - 매우 안정적 + Review veryStableReview = Review.builder() + .content("발목 지지력이 매우 안정적이어서 운동할 때 최고예요") + .rating(5) + .userId(2L) + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.NORMAL) + .stability(Stability.VERY_STABLE) + .status(ReviewStatus.ACTIVE) + .build(); + + // when & then + assertEquals(Stability.VERY_UNSTABLE, veryUnstableReview.getStability()); + assertEquals(Stability.VERY_STABLE, veryStableReview.getStability()); + assertTrue(veryUnstableReview.getContent().contains("매우 불안정")); + assertTrue(veryStableReview.getContent().contains("매우 안정적")); + } + + @Test + @DisplayName("Given shoe review update_When change characteristics_Then update successfully") + void givenShoeReviewUpdate_whenChangeCharacteristics_thenUpdateSuccessfully() { + // given + Review review = createValidShoeReview(); + + // when - 시간이 지나면서 느낌이 변함 + review.updateSizeFit(SizeFit.BIG); // 늘어나서 커짐 + review.updateCushioning(Cushion.FIRM); // 쿠션이 눌려서 단단해짐 + review.updateStability(Stability.UNSTABLE); // 안정성도 떨어짐 + + // then + assertEquals(SizeFit.BIG, review.getSizeFit()); + assertEquals(Cushion.FIRM, review.getCushioning()); + assertEquals(Stability.UNSTABLE, review.getStability()); + } + + private Review createValidShoeReview() { + return Review.builder() + .content("편안하고 스타일리시한 신발입니다") + .rating(5) + .userId(1L) + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.SOFT) + .stability(Stability.STABLE) + .status(ReviewStatus.ACTIVE) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/domain/entity/SizeFitTest.java b/src/test/java/com/cMall/feedShop/review/domain/entity/SizeFitTest.java new file mode 100644 index 000000000..0839eb5ca --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/domain/entity/SizeFitTest.java @@ -0,0 +1,54 @@ +package com.cMall.feedShop.review.domain.entity; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import static org.junit.jupiter.api.Assertions.*; + +class SizeFitTest { + + @Test + @DisplayName("Given 5-level size fit values_When get all values_Then return complete enum set") + void given5LevelSizeFitValues_whenGetAllValues_thenReturnCompleteEnumSet() { + // given & when + SizeFit[] allSizeFits = SizeFit.values(); + + // then + assertEquals(5, allSizeFits.length); // 정확히 5개 + + // 신발 사이즈 핏 5단계가 모두 존재하는지 확인 + boolean hasVerySmall = false, hasSmall = false, hasPerfect = false, + hasBig = false, hasVeryBig = false; + + for (SizeFit sizeFit : allSizeFits) { + switch (sizeFit) { + case VERY_SMALL: hasVerySmall = true; break; // 매우 작음 + case SMALL: hasSmall = true; break; // 작음 + case PERFECT: hasPerfect = true; break; // 딱 맞음 + case BIG: hasBig = true; break; // 큼 + case VERY_BIG: hasVeryBig = true; break; // 매우 큼 + } + } + + assertTrue(hasVerySmall && hasSmall && hasPerfect && hasBig && hasVeryBig); + } + + @Test + @DisplayName("Given size fit comparison_When check fit level_Then order correctly") + void givenSizeFitComparison_whenCheckFitLevel_thenOrderCorrectly() { + // given & when & then + // 신발 사이즈 순서: 매우 작음 < 작음 < 딱 맞음 < 큼 < 매우 큼 + assertTrue(SizeFit.VERY_SMALL.ordinal() < SizeFit.SMALL.ordinal()); + assertTrue(SizeFit.SMALL.ordinal() < SizeFit.PERFECT.ordinal()); + assertTrue(SizeFit.PERFECT.ordinal() < SizeFit.BIG.ordinal()); + assertTrue(SizeFit.BIG.ordinal() < SizeFit.VERY_BIG.ordinal()); + } + + @Test + @DisplayName("Given size fit extremes_When evaluate comfort_Then return appropriate assessment") + void givenSizeFitExtremes_whenEvaluateComfort_thenReturnAppropriateAssessment() { + // given & when & then + assertEquals("VERY_SMALL", SizeFit.VERY_SMALL.name()); // 발가락 심하게 눌림 + assertEquals("PERFECT", SizeFit.PERFECT.name()); // 이상적인 핏 + assertEquals("VERY_BIG", SizeFit.VERY_BIG.name()); // 발이 헐렁거림 + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/domain/entity/StabilityTest.java b/src/test/java/com/cMall/feedShop/review/domain/entity/StabilityTest.java new file mode 100644 index 000000000..77789aeba --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/domain/entity/StabilityTest.java @@ -0,0 +1,71 @@ +package com.cMall.feedShop.review.domain.entity; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import static org.junit.jupiter.api.Assertions.*; + +class StabilityTest { + + @Test + @DisplayName("Given 5-level stability values_When get all values_Then return complete enum set") + void given5LevelStabilityValues_whenGetAllValues_thenReturnCompleteEnumSet() { + // given & when + Stability[] allStabilities = Stability.values(); + + // then + assertEquals(5, allStabilities.length); // 정확히 5개 + + // 신발 안정성 5단계가 모두 존재하는지 확인 + boolean hasVeryUnstable = false, hasUnstable = false, hasNormal = false, + hasStable = false, hasVeryStable = false; + + for (Stability stability : allStabilities) { + switch (stability) { + case VERY_UNSTABLE: hasVeryUnstable = true; break; // 매우 불안정 + case UNSTABLE: hasUnstable = true; break; // 불안정 + case NORMAL: hasNormal = true; break; // 보통 + case STABLE: hasStable = true; break; // 안정적 + case VERY_STABLE: hasVeryStable = true; break; // 매우 안정적 + } + } + + assertTrue(hasVeryUnstable && hasUnstable && hasNormal && hasStable && hasVeryStable); + } + + @Test + @DisplayName("Given stability comparison_When check support level_Then order correctly") + void givenStabilityComparison_whenCheckSupportLevel_thenOrderCorrectly() { + // given & when & then + // 신발 안정성 순서: 매우 불안정 < 불안정 < 보통 < 안정적 < 매우 안정적 + assertTrue(Stability.VERY_UNSTABLE.ordinal() < Stability.UNSTABLE.ordinal()); + assertTrue(Stability.UNSTABLE.ordinal() < Stability.NORMAL.ordinal()); + assertTrue(Stability.NORMAL.ordinal() < Stability.STABLE.ordinal()); + assertTrue(Stability.STABLE.ordinal() < Stability.VERY_STABLE.ordinal()); + } + + @Test + @DisplayName("Given stability extremes_When evaluate ankle support_Then return appropriate assessment") + void givenStabilityExtremes_whenEvaluateAnkleSupport_thenReturnAppropriateAssessment() { + // given & when & then + assertEquals("VERY_UNSTABLE", Stability.VERY_UNSTABLE.name()); // 발목 삐끗 위험 + assertEquals("NORMAL", Stability.NORMAL.name()); // 일반적인 지지력 + assertEquals("VERY_STABLE", Stability.VERY_STABLE.name()); // 강력한 발목 지지 + } + + @Test + @DisplayName("Given stability requirement by terrain_When choose stability level_Then match terrain needs") + void givenStabilityRequirementByTerrain_whenChooseStabilityLevel_thenMatchTerrainNeeds() { + // given & when & then + // 평지 걷기: 보통 안정성으로도 충분 + Stability flatWalking = Stability.NORMAL; + assertEquals(Stability.NORMAL, flatWalking); + + // 등산/트레킹: 높은 안정성 필요 + Stability hiking = Stability.STABLE; + assertEquals(Stability.STABLE, hiking); + + // 험한 산악지형: 최고 안정성 필요 + Stability extremeTerrain = Stability.VERY_STABLE; + assertEquals(Stability.VERY_STABLE, extremeTerrain); + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/domain/repository/ReviewImageRepositoryTest.java b/src/test/java/com/cMall/feedShop/review/domain/repository/ReviewImageRepositoryTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/domain/repository/ReviewRepositoryTest.java b/src/test/java/com/cMall/feedShop/review/domain/repository/ReviewRepositoryTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/fixture/CushionFixture.java b/src/test/java/com/cMall/feedShop/review/fixture/CushionFixture.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/fixture/ReviewFixture.java b/src/test/java/com/cMall/feedShop/review/fixture/ReviewFixture.java new file mode 100644 index 000000000..8f0e7a377 --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/fixture/ReviewFixture.java @@ -0,0 +1,95 @@ +package com.cMall.feedShop.review.fixture; + +import com.cMall.feedShop.review.domain.entity.Review; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.Stability; +import com.cMall.feedShop.review.domain.entity.ReviewStatus; +import com.cMall.feedShop.review.application.dto.request.ReviewCreateRequest; + +public class ReviewFixture { + + // 최고 등급 신발 리뷰 + public static ReviewCreateRequest createPremiumShoeReviewRequest() { + return ReviewCreateRequest.builder() + .content("최고급 신발입니다. 모든 면에서 완벽해요") + .rating(5) + .userId(1L) + .productId(1L) + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .build(); + } + + // 최악 등급 신발 리뷰 + public static ReviewCreateRequest createWorstShoeReviewRequest() { + return ReviewCreateRequest.builder() + .content("최악의 신발입니다. 모든 게 다 별로예요") + .rating(1) + .userId(1L) + .productId(1L) + .sizeFit(SizeFit.VERY_SMALL) + .cushioning(Cushion.VERY_FIRM) + .stability(Stability.VERY_UNSTABLE) + .build(); + } + + // 각 특성별 극단값 테스트용 + public static Review createReviewWithExtremeSizeFit(SizeFit sizeFit) { + String content = switch (sizeFit) { + case VERY_SMALL -> "발가락이 심하게 눌려요"; + case VERY_BIG -> "발이 신발 안에서 헤엄쳐요"; + default -> "사이즈 " + sizeFit.name(); + }; + + return Review.builder() + .content(content) + .rating(sizeFit == SizeFit.PERFECT ? 5 : 2) + .userId(1L) + .productId(1L) + .sizeFit(sizeFit) + .cushioning(Cushion.NORMAL) + .stability(Stability.NORMAL) + .status(ReviewStatus.ACTIVE) + .build(); + } + + public static Review createReviewWithExtremeCushioning(Cushion cushioning) { + String content = switch (cushioning) { + case VERY_SOFT -> "구름 위를 걷는 느낌"; + case VERY_FIRM -> "바닥을 그대로 느껴요"; + default -> "쿠션감 " + cushioning.name(); + }; + + return Review.builder() + .content(content) + .rating(cushioning == Cushion.VERY_SOFT ? 5 : 2) + .userId(1L) + .productId(1L) + .sizeFit(SizeFit.PERFECT) + .cushioning(cushioning) + .stability(Stability.NORMAL) + .status(ReviewStatus.ACTIVE) + .build(); + } + + public static Review createReviewWithExtremeStability(Stability stability) { + String content = switch (stability) { + case VERY_STABLE -> "발목이 완전히 고정된 느낌"; + case VERY_UNSTABLE -> "한 발짝마다 삐끗할 것 같아요"; + default -> "안정성 " + stability.name(); + }; + + return Review.builder() + .content(content) + .rating(stability == Stability.VERY_STABLE ? 5 : 2) + .userId(1L) + .productId(1L) + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.NORMAL) + .stability(stability) + .status(ReviewStatus.ACTIVE) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/fixture/ReviewImageFixture.java b/src/test/java/com/cMall/feedShop/review/fixture/ReviewImageFixture.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/fixture/ReviewRequestFixture.java b/src/test/java/com/cMall/feedShop/review/fixture/ReviewRequestFixture.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/fixture/ReviewResponseFixture.java b/src/test/java/com/cMall/feedShop/review/fixture/ReviewResponseFixture.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/fixture/TestDataBuilder.java b/src/test/java/com/cMall/feedShop/review/fixture/TestDataBuilder.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewImageJpaRepositoryTest.java b/src/test/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewImageJpaRepositoryTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewImageRepositoryImplTest.java b/src/test/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewImageRepositoryImplTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewJpaRepositoryTest.java b/src/test/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewJpaRepositoryTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewRepositoryImplTest.java b/src/test/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewRepositoryImplTest.java new file mode 100644 index 000000000..9ca766673 --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/infrastructure/jpa/ReviewRepositoryImplTest.java @@ -0,0 +1,165 @@ +package com.cMall.feedShop.review.infrastructure.jpa; + +import com.cMall.feedShop.review.domain.entity.Review; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.Stability; +import com.cMall.feedShop.review.domain.entity.ReviewStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.beans.factory.annotation.Autowired; +import static org.junit.jupiter.api.Assertions.*; +import java.util.List; +import java.util.Optional; + +@DataJpaTest +class ReviewRepositoryImplTest { + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private ReviewJpaRepository reviewJpaRepository; + + @Test + @DisplayName("Given shoe review with 5-level characteristics_When save_Then persist all levels correctly") + void givenShoeReviewWith5LevelCharacteristics_whenSave_thenPersistAllLevelsCorrectly() { + // given + Review extremeReview = Review.builder() + .content("극단적인 특성의 신발 테스트") + .rating(1) + .userId(1L) + .productId(1L) + .sizeFit(SizeFit.VERY_SMALL) // 매우 작음 + .cushioning(Cushion.VERY_FIRM) // 매우 단단함 + .stability(Stability.VERY_UNSTABLE) // 매우 불안정 + .status(ReviewStatus.ACTIVE) + .build(); + + // when + Review savedReview = reviewJpaRepository.save(extremeReview); + entityManager.flush(); + + // then + assertNotNull(savedReview.getId()); + assertEquals(SizeFit.VERY_SMALL, savedReview.getSizeFit()); + assertEquals(Cushion.VERY_FIRM, savedReview.getCushioning()); + assertEquals(Stability.VERY_UNSTABLE, savedReview.getStability()); + } +/* + @Test + @DisplayName("Given various size fit levels_When find by each level_Then return accurate filtering") + void givenVariousSizeFitLevels_whenFindByEachLevel_thenReturnAccurateFiltering() { + // given - 5가지 사이즈 핏 모두 생성 + Long productId = 1L; + createShoeReviewWithSizeFit("매우 작은 신발", productId, SizeFit.VERY_SMALL); + createShoeReviewWithSizeFit("작은 신발", productId, SizeFit.SMALL); + createShoeReviewWithSizeFit("딱 맞는 신발", productId, SizeFit.PERFECT); + createShoeReviewWithSizeFit("큰 신발", productId, SizeFit.BIG); + createShoeReviewWithSizeFit("매우 큰 신발", productId, SizeFit.VERY_BIG); + + // when & then - 각 레벨별로 정확히 조회되는지 확인 + List verySmallReviews = reviewJpaRepository.findByProductIdAndSizeFitAndStatus( + productId, SizeFit.VERY_SMALL, ReviewStatus.ACTIVE); + assertEquals(1, verySmallReviews.size()); + assertTrue(verySmallReviews.get(0).getContent().contains("매우 작은")); + + List perfectReviews = reviewJpaRepository.findByProductIdAndSizeFitAndStatus( + productId, SizeFit.PERFECT, ReviewStatus.ACTIVE); + assertEquals(1, perfectReviews.size()); + assertTrue(perfectReviews.get(0).getContent().contains("딱 맞는")); + + List veryBigReviews = reviewJpaRepository.findByProductIdAndSizeFitAndStatus( + productId, SizeFit.VERY_BIG, ReviewStatus.ACTIVE); + assertEquals(1, veryBigReviews.size()); + assertTrue(veryBigReviews.get(0).getContent().contains("매우 큰")); + } + + @Test + @DisplayName("Given various cushioning levels_When find by extreme levels_Then return matching reviews") + void givenVariousCushioningLevels_whenFindByExtremeLevels_thenReturnMatchingReviews() { + // given - 극단적인 쿠션 레벨들 + Long productId = 1L; + createShoeReviewWithCushioning("구름같은 쿠션", productId, Cushion.VERY_SOFT); + createShoeReviewWithCushioning("바위같은 쿠션", productId, Cushion.VERY_FIRM); + createShoeReviewWithCushioning("적당한 쿠션", productId, Cushion.NORMAL); + + // when & then - 매우 부드러운 쿠션만 조회 + List verySoftReviews = reviewJpaRepository.findByProductIdAndCushioningAndStatus( + productId, Cushion.VERY_SOFT, ReviewStatus.ACTIVE); + assertEquals(1, verySoftReviews.size()); + assertTrue(verySoftReviews.get(0).getContent().contains("구름같은")); + + // when & then - 매우 단단한 쿠션만 조회 + List veryFirmReviews = reviewJpaRepository.findByProductIdAndCushioningAndStatus( + productId, Cushion.VERY_FIRM, ReviewStatus.ACTIVE); + assertEquals(1, veryFirmReviews.size()); + assertTrue(veryFirmReviews.get(0).getContent().contains("바위같은")); + } + + @Test + @DisplayName("Given stability extremes_When find by very stable and very unstable_Then return appropriate reviews") + void givenStabilityExtremes_whenFindByVeryStableAndVeryUnstable_thenReturnAppropriateReviews() { + // given + Long productId = 1L; + createShoeReviewWithStability("발목 완전 고정", productId, Stability.VERY_STABLE); + createShoeReviewWithStability("발목 완전 불안", productId, Stability.VERY_UNSTABLE); + createShoeReviewWithStability("보통 지지력", productId, Stability.NORMAL); + + // when & then - 매우 안정적인 것만 조회 + List veryStableReviews = reviewJpaRepository.findByProductIdAndStabilityAndStatus( + productId, Stability.VERY_STABLE, ReviewStatus.ACTIVE); + assertEquals(1, veryStableReviews.size()); + assertTrue(veryStableReviews.get(0).getContent().contains("완전 고정")); + + // when & then - 매우 불안정한 것만 조회 + List veryUnstableReviews = reviewJpaRepository.findByProductIdAndStabilityAndStatus( + productId, Stability.VERY_UNSTABLE, ReviewStatus.ACTIVE); + assertEquals(1, veryUnstableReviews.size()); + assertTrue(veryUnstableReviews.get(0).getContent().contains("완전 불안")); + } +*/ + private Review createShoeReviewWithSizeFit(String content, Long productId, SizeFit sizeFit) { + Review review = Review.builder() + .content(content) + .rating(3) + .userId(1L) + .productId(productId) + .sizeFit(sizeFit) + .cushioning(Cushion.NORMAL) + .stability(Stability.NORMAL) + .status(ReviewStatus.ACTIVE) + .build(); + return reviewJpaRepository.save(review); + } + + private Review createShoeReviewWithCushioning(String content, Long productId, Cushion cushioning) { + Review review = Review.builder() + .content(content) + .rating(3) + .userId(1L) + .productId(productId) + .sizeFit(SizeFit.PERFECT) + .cushioning(cushioning) + .stability(Stability.NORMAL) + .status(ReviewStatus.ACTIVE) + .build(); + return reviewJpaRepository.save(review); + } + + private Review createShoeReviewWithStability(String content, Long productId, Stability stability) { + Review review = Review.builder() + .content(content) + .rating(3) + .userId(1L) + .productId(productId) + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.NORMAL) + .stability(stability) + .status(ReviewStatus.ACTIVE) + .build(); + return reviewJpaRepository.save(review); + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/integration/ReviewApiIntegrationTest.java b/src/test/java/com/cMall/feedShop/review/integration/ReviewApiIntegrationTest.java new file mode 100644 index 000000000..1bf82ec9e --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/integration/ReviewApiIntegrationTest.java @@ -0,0 +1,280 @@ +package com.cMall.feedShop.review.integration; + +import com.cMall.feedShop.review.application.dto.request.ReviewCreateRequest; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.Stability; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureWebMvc +@ActiveProfiles("test") +@Transactional +class ReviewApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("API Integration: 완전한 리뷰 CRUD 워크플로우") + void completeReviewCrudWorkflow() throws Exception { + // 1. CREATE - 리뷰 생성 + ReviewCreateRequest createRequest = ReviewCreateRequest.builder() + .userId(1L) + .productId(1L) + .reviewTitle("API 테스트 리뷰") + .rating(5) + .content("완벽한 신발입니다. 5단계 평가 모두 최고!") + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .imageUrls(new ArrayList<>()) + .build(); + + MvcResult createResult = mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.reviewId").exists()) + .andExpect(jsonPath("$.rating").value(5)) + .andExpect(jsonPath("$.sizeFit").value("PERFECT")) + .andExpect(jsonPath("$.cushioning").value("VERY_SOFT")) + .andExpect(jsonPath("$.stability").value("VERY_STABLE")) + .andDo(print()) + .andReturn(); + + // 생성된 리뷰 ID 추출 + String responseContent = createResult.getResponse().getContentAsString(); + Long reviewId = objectMapper.readTree(responseContent).get("reviewId").asLong(); + + // 2. READ - 리뷰 상세 조회 + mockMvc.perform(get("/api/reviews/{reviewId}", reviewId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.reviewId").value(reviewId)) + .andExpect(jsonPath("$.reviewTitle").value("API 테스트 리뷰")) + .andExpect(jsonPath("$.rating").value(5)) + .andExpect(jsonPath("$.sizeFit").value("PERFECT")) + .andExpect(jsonPath("$.cushioning").value("VERY_SOFT")) + .andExpect(jsonPath("$.stability").value("VERY_STABLE")) + .andDo(print()); + + // 3. READ - 사용자별 리뷰 목록 조회 + mockMvc.perform(get("/api/users/{userId}/reviews", 1L) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].reviewId").value(reviewId)) + .andExpect(jsonPath("$.totalElements").value(1)) + .andDo(print()); + + // 4. DELETE - 리뷰 삭제 + mockMvc.perform(delete("/api/reviews/{reviewId}", reviewId) + .param("userId", "1")) + .andExpect(status().isNoContent()) + .andDo(print()); + } + + @Test + @DisplayName("API Integration: 상품별 리뷰 통계 및 요약 조회") + void productReviewStatisticsAndSummary() throws Exception { + Long productId = 1L; + + // 다양한 평점의 리뷰들 생성 + createReviewViaApi(1L, productId, 5, SizeFit.PERFECT, Cushion.VERY_SOFT, Stability.VERY_STABLE); + createReviewViaApi(2L, productId, 4, SizeFit.PERFECT, Cushion.SOFT, Stability.STABLE); + createReviewViaApi(3L, productId, 3, SizeFit.BIG, Cushion.NORMAL, Stability.NORMAL); + createReviewViaApi(4L, productId, 2, SizeFit.SMALL, Cushion.FIRM, Stability.UNSTABLE); + createReviewViaApi(5L, productId, 1, SizeFit.VERY_SMALL, Cushion.VERY_FIRM, Stability.VERY_UNSTABLE); + + // 상품 리뷰 요약 조회 + mockMvc.perform(get("/api/products/{productId}/reviews/summary", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.productId").value(productId)) + .andExpect(jsonPath("$.totalReviews").value(5)) + .andExpect(jsonPath("$.averageRating").value(3.0)) // (5+4+3+2+1)/5 = 3.0 + .andExpect(jsonPath("$.mostCommonSizeFit").exists()) + .andExpect(jsonPath("$.recentReviews").isArray()) + .andDo(print()); + + // 상품 리뷰 통계 조회 + mockMvc.perform(get("/api/products/{productId}/reviews/statistics", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.productId").value(productId)) + .andExpect(jsonPath("$.totalReviews").value(5)) + .andExpect(jsonPath("$.averageRating").value(3.0)) + .andExpect(jsonPath("$.ratingDistribution.5").value(1)) + .andExpect(jsonPath("$.ratingDistribution.4").value(1)) + .andExpect(jsonPath("$.ratingDistribution.3").value(1)) + .andExpect(jsonPath("$.ratingDistribution.2").value(1)) + .andExpect(jsonPath("$.ratingDistribution.1").value(1)) + .andDo(print()); + } + + @Test + @DisplayName("API Integration: 5단계 특성별 리뷰 필터링") + void characteristicBasedFiltering() throws Exception { + Long productId = 1L; + + // 다양한 특성의 리뷰들 생성 + createReviewViaApi(1L, productId, 5, SizeFit.PERFECT, Cushion.VERY_SOFT, Stability.VERY_STABLE); + createReviewViaApi(2L, productId, 5, SizeFit.PERFECT, Cushion.VERY_SOFT, Stability.STABLE); + createReviewViaApi(3L, productId, 4, SizeFit.BIG, Cushion.SOFT, Stability.NORMAL); + + // 사이즈 핏별 필터링 (가상의 엔드포인트) + mockMvc.perform(get("/api/products/{productId}/reviews", productId) + .param("sizeFit", "PERFECT") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)) // PERFECT 사이즈 2개 + .andDo(print()); + + // 쿠셔닝별 필터링 (가상의 엔드포인트) + mockMvc.perform(get("/api/products/{productId}/reviews", productId) + .param("cushioning", "VERY_SOFT") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)) // VERY_SOFT 쿠셔닝 2개 + .andDo(print()); + } + + @Test + @DisplayName("API Integration: 시간 경과에 따른 특성 변화 업데이트") + void characteristicsChangeOverTime() throws Exception { + // 초기 완벽한 리뷰 생성 + ReviewCreateRequest initialRequest = ReviewCreateRequest.builder() + .userId(1L) + .productId(1L) + .reviewTitle("처음엔 완벽했던 신발") + .rating(5) + .content("처음엔 모든 게 완벽했어요") + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .imageUrls(new ArrayList<>()) + .build(); + + MvcResult createResult = mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(initialRequest))) + .andExpect(status().isCreated()) + .andReturn(); + + Long reviewId = objectMapper.readTree(createResult.getResponse().getContentAsString()) + .get("reviewId").asLong(); + + // 시간 경과 후 특성 변화 업데이트 + com.cMall.feedShop.review.application.dto.request.ReviewUpdateRequest updateRequest = + com.cMall.feedShop.review.application.dto.request.ReviewUpdateRequest.builder() + .reviewTitle("한 달 후 재평가 - 많이 변했어요") + .rating(2) + .content("신발이 늘어나고 쿠션이 주저앉아서 많이 아쉬워졌어요") + .sizeFit(SizeFit.BIG) + .cushioning(Cushion.FIRM) + .stability(Stability.UNSTABLE) + .build(); + + // 업데이트 수행 + mockMvc.perform(put("/api/reviews/{reviewId}", reviewId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andDo(print()); + + // 업데이트된 내용 확인 + mockMvc.perform(get("/api/reviews/{reviewId}", reviewId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.reviewTitle").value("한 달 후 재평가 - 많이 변했어요")) + .andExpect(jsonPath("$.rating").value(2)) + .andExpect(jsonPath("$.sizeFit").value("BIG")) + .andExpect(jsonPath("$.cushioning").value("FIRM")) + .andExpect(jsonPath("$.stability").value("UNSTABLE")) + .andExpect(jsonPath("$.content").value(containsString("주저앉아서"))) + .andDo(print()); + } + + @Test + @DisplayName("API Integration: 에러 케이스 처리") + void errorCasesHandling() throws Exception { + // 1. 존재하지 않는 리뷰 조회 + mockMvc.perform(get("/api/reviews/{reviewId}", 99999L)) + .andExpect(status().isNotFound()) + .andDo(print()); + + // 2. 잘못된 상품 ID로 통계 조회 + mockMvc.perform(get("/api/products/{productId}/reviews/statistics", 99999L)) + .andExpect(status().isNotFound()) + .andDo(print()); + + // 3. 중복 리뷰 생성 시도 + ReviewCreateRequest request = ReviewCreateRequest.builder() + .userId(1L) + .productId(1L) + .reviewTitle("첫 번째 리뷰") + .rating(5) + .content("완벽한 신발") + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .imageUrls(new ArrayList<>()) + .build(); + + // 첫 번째 리뷰 생성 + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + + // 같은 사용자가 같은 상품에 중복 리뷰 시도 + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(containsString("이미 해당 상품에 대한 리뷰를 작성하셨습니다"))) + .andDo(print()); + } + + // 헬퍼 메서드 + private void createReviewViaApi(Long userId, Long productId, int rating, + SizeFit sizeFit, Cushion cushioning, Stability stability) throws Exception { + ReviewCreateRequest request = ReviewCreateRequest.builder() + .userId(userId) + .productId(productId) + .reviewTitle("테스트 리뷰 " + userId) + .rating(rating) + .content("평점 " + rating + "점 리뷰입니다") + .sizeFit(sizeFit) + .cushioning(cushioning) + .stability(stability) + .imageUrls(new ArrayList<>()) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/integration/ReviewDatabaseIntegrationTest.java b/src/test/java/com/cMall/feedShop/review/integration/ReviewDatabaseIntegrationTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/integration/ReviewImageIntegrationTest.java b/src/test/java/com/cMall/feedShop/review/integration/ReviewImageIntegrationTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/integration/ReviewIntegrationTest.java b/src/test/java/com/cMall/feedShop/review/integration/ReviewIntegrationTest.java new file mode 100644 index 000000000..717d6995d --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/integration/ReviewIntegrationTest.java @@ -0,0 +1,245 @@ +package com.cMall.feedShop.review.integration; + +import com.cMall.feedShop.review.application.ReviewService; +import com.cMall.feedShop.review.application.ReviewStatisticsService; +import com.cMall.feedShop.review.application.dto.request.ReviewCreateRequest; +import com.cMall.feedShop.review.application.dto.response.ReviewCreateResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewDetailResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewStatisticsResponse; +import com.cMall.feedShop.review.domain.entity.*; +import com.cMall.feedShop.review.domain.repository.ReviewRepository; +import com.cMall.feedShop.review.domain.repository.ReviewImageRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) +@TestPropertySource(locations = "classpath:application-test.properties") +@Transactional +@ActiveProfiles("test") +public class ReviewIntegrationTest { + + @Autowired + private ReviewService reviewService; + + @Autowired + private ReviewStatisticsService reviewStatisticsService; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private ReviewImageRepository reviewImageRepository; + + private ReviewCreateRequest perfectShoeRequest; + private ReviewCreateRequest badShoeRequest; + + @BeforeEach + void setUp() { + // 완벽한 신발 리뷰 + perfectShoeRequest = ReviewCreateRequest.builder() + .userId(1L) + .productId(1L) + .reviewTitle("완벽한 신발입니다!") + .rating(5) + .content("사이즈 완벽, 쿠션 매우 부드럽고 안정감 최고") + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .imageUrls(new ArrayList<>()) + .build(); + + // 최악의 신발 리뷰 + badShoeRequest = ReviewCreateRequest.builder() + .userId(2L) + .productId(1L) + .reviewTitle("최악의 신발") + .rating(1) + .content("사이즈 너무 작고 딱딱하며 불안정함") + .sizeFit(SizeFit.VERY_SMALL) + .cushioning(Cushion.VERY_FIRM) + .stability(Stability.VERY_UNSTABLE) + .imageUrls(new ArrayList<>()) + .build(); + } + + @Test + @DisplayName("Integration: 완전한 리뷰 생성부터 조회까지의 전체 흐름") + void completeReviewWorkflowIntegrationTest() { + // Given: 리뷰 데이터 준비 + + // When: 1단계 - 리뷰 생성 + ReviewCreateResponse createResponse = reviewService.createReview(perfectShoeRequest); + + // Then: 1단계 검증 - 리뷰가 정상 생성되었는지 + assertNotNull(createResponse); + assertNotNull(createResponse.getReviewId()); + assertEquals(5, createResponse.getRating()); + assertEquals(SizeFit.PERFECT, createResponse.getSizeFit()); + assertEquals(Cushion.VERY_SOFT, createResponse.getCushioning()); + assertEquals(Stability.VERY_STABLE, createResponse.getStability()); + + Long reviewId = createResponse.getReviewId(); + + // When: 2단계 - 생성된 리뷰 상세 조회 + ReviewDetailResponse detailResponse = reviewService.getReviewDetail(reviewId); + + // Then: 2단계 검증 - 상세 조회 결과 + assertNotNull(detailResponse); + assertEquals(reviewId, detailResponse.getReviewId()); + assertEquals("완벽한 신발입니다!", detailResponse.getReviewTitle()); + assertEquals(5, detailResponse.getRating()); + assertEquals(SizeFit.PERFECT, detailResponse.getSizeFit()); + assertEquals(Cushion.VERY_SOFT, detailResponse.getCushioning()); + assertEquals(Stability.VERY_STABLE, detailResponse.getStability()); + + // When: 3단계 - 사용자별 리뷰 목록 조회 + Page userReviews = reviewService.getUserReviews(1L, PageRequest.of(0, 10)); + + // Then: 3단계 검증 - 사용자 리뷰 목록 + assertNotNull(userReviews); + assertEquals(1, userReviews.getTotalElements()); + assertEquals(reviewId, userReviews.getContent().get(0).getReviewId()); + } + + @Test + @DisplayName("Integration: 다중 리뷰 생성 후 필터링 및 통계 생성") + void multipleReviewsWithFilteringAndStatistics() { + // Given: 여러 개의 다양한 리뷰 생성 + reviewService.createReview(perfectShoeRequest); + reviewService.createReview(badShoeRequest); + + // 중간 수준 리뷰 추가 + ReviewCreateRequest mediumRequest = ReviewCreateRequest.builder() + .userId(3L) + .productId(1L) + .reviewTitle("괜찮은 신발") + .rating(3) + .content("보통 수준의 신발입니다") + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.NORMAL) + .stability(Stability.NORMAL) + .imageUrls(new ArrayList<>()) + .build(); + reviewService.createReview(mediumRequest); + + // When: 1단계 - 사이즈 핏별 필터링 + List perfectFitReviews = + reviewService.getReviewsBySizeFit(1L, SizeFit.PERFECT); + + // Then: 1단계 검증 - PERFECT 핏 리뷰는 2개 + assertEquals(2, perfectFitReviews.size()); + + // When: 2단계 - 쿠셔닝별 필터링 + List verySoftReviews = + reviewService.getReviewsByCushioning(1L, Cushion.VERY_SOFT); + + // Then: 2단계 검증 - VERY_SOFT 쿠셔닝은 1개 + assertEquals(1, verySoftReviews.size()); + assertEquals(5, verySoftReviews.get(0).getRating()); + + // When: 3단계 - 통계 생성 + ReviewStatisticsResponse statistics = reviewStatisticsService.getProductStatistics(1L); + + // Then: 3단계 검증 - 통계 계산 + assertNotNull(statistics); + assertEquals(1L, statistics.getProductId()); + assertEquals(3L, statistics.getTotalReviews()); + assertEquals(3.0, statistics.getAverageRating(), 0.1); // (5+1+3)/3 = 3.0 + + // 평점 분포 검증 + assertEquals(1L, statistics.getRatingDistribution().get(5)); // 5점 1개 + assertEquals(1L, statistics.getRatingDistribution().get(3)); // 3점 1개 + assertEquals(1L, statistics.getRatingDistribution().get(1)); // 1점 1개 + } + + @Test + @DisplayName("Integration: 시간 경과에 따른 신발 특성 변화 업데이트") + void shoeCharacteristicsChangeOverTime() { + // Given: 초기에 완벽한 리뷰 작성 + ReviewCreateResponse initialReview = reviewService.createReview(perfectShoeRequest); + Long reviewId = initialReview.getReviewId(); + + // When: 시간이 지나서 특성이 변화 (신발이 늘어나고 쿠션이 주저앉음) + com.cMall.feedShop.review.application.dto.request.ReviewUpdateRequest updateRequest = + com.cMall.feedShop.review.application.dto.request.ReviewUpdateRequest.builder() + .reviewTitle("한 달 후 재평가") + .rating(3) + .content("처음엔 좋았지만 시간이 지나니 늘어나고 쿠션이 주저앉았어요") + .sizeFit(SizeFit.BIG) // PERFECT → BIG (늘어남) + .cushioning(Cushion.FIRM) // VERY_SOFT → FIRM (주저앉음) + .stability(Stability.UNSTABLE) // VERY_STABLE → UNSTABLE (안정성 저하) + .build(); + + reviewService.updateReview(reviewId, updateRequest); + + // Then: 업데이트된 내용 검증 + ReviewDetailResponse updatedReview = reviewService.getReviewDetail(reviewId); + + assertEquals("한 달 후 재평가", updatedReview.getReviewTitle()); + assertEquals(3, updatedReview.getRating()); + assertEquals(SizeFit.BIG, updatedReview.getSizeFit()); + assertEquals(Cushion.FIRM, updatedReview.getCushioning()); + assertEquals(Stability.UNSTABLE, updatedReview.getStability()); + assertTrue(updatedReview.getContent().contains("주저앉았어요")); + } + + @Test + @DisplayName("Integration: 중복 리뷰 방지 및 예외 처리") + void duplicateReviewPrevention() { + // Given: 첫 번째 리뷰 작성 + reviewService.createReview(perfectShoeRequest); + + // When & Then: 같은 사용자가 같은 상품에 다시 리뷰 작성 시도 + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> reviewService.createReview(perfectShoeRequest) + ); + + assertEquals("이미 해당 상품에 대한 리뷰를 작성하셨습니다.", exception.getMessage()); + } + + @Test + @DisplayName("Integration: 5단계 특성별 평균 평점 계산") + void averageRatingByCharacteristics() { + // Given: 다양한 특성의 리뷰들 생성 + reviewService.createReview(perfectShoeRequest); // VERY_SOFT, 5점 + + ReviewCreateRequest softRequest = ReviewCreateRequest.builder() + .userId(4L) + .productId(1L) + .reviewTitle("부드러운 신발") + .rating(4) + .content("쿠션이 부드러워요") + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.STABLE) + .imageUrls(new ArrayList<>()) + .build(); + reviewService.createReview(softRequest); // VERY_SOFT, 4점 + + // When: 특성별 평균 평점 조회 + Double verySoftAverage = reviewStatisticsService.getAverageRatingByCushioning(Cushion.VERY_SOFT); + Double perfectFitAverage = reviewStatisticsService.getAverageRatingBySizeFit(SizeFit.PERFECT); + Double veryStableAverage = reviewStatisticsService.getAverageRatingByStability(Stability.VERY_STABLE); + + // Then: 계산된 평균 검증 + assertEquals(4.5, verySoftAverage, 0.1); // (5+4)/2 = 4.5 + assertEquals(4.5, perfectFitAverage, 0.1); // (5+4)/2 = 4.5 + assertEquals(5.0, veryStableAverage, 0.1); // 5/1 = 5.0 + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/integration/ReviewReportIntegrationTest.java b/src/test/java/com/cMall/feedShop/review/integration/ReviewReportIntegrationTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/integration/ReviewStatisticsIntegrationTest.java b/src/test/java/com/cMall/feedShop/review/integration/ReviewStatisticsIntegrationTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/performance/ReviewBulkOperationTest.java b/src/test/java/com/cMall/feedShop/review/performance/ReviewBulkOperationTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/performance/ReviewConcurrencyTest.java b/src/test/java/com/cMall/feedShop/review/performance/ReviewConcurrencyTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/performance/ReviewPerformanceTest.java b/src/test/java/com/cMall/feedShop/review/performance/ReviewPerformanceTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/performance/ReviewSearchPerformanceTest.java b/src/test/java/com/cMall/feedShop/review/performance/ReviewSearchPerformanceTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/presentation/ReviewApiTest.java b/src/test/java/com/cMall/feedShop/review/presentation/ReviewApiTest.java new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/com/cMall/feedShop/review/presentation/ReviewControllerTest.java b/src/test/java/com/cMall/feedShop/review/presentation/ReviewControllerTest.java new file mode 100644 index 000000000..9cf454d3d --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/presentation/ReviewControllerTest.java @@ -0,0 +1,73 @@ +package com.cMall.feedShop.review.presentation; + +import com.cMall.feedShop.review.application.ReviewService; +import com.cMall.feedShop.review.application.dto.request.ReviewCreateRequest; +import com.cMall.feedShop.review.application.dto.response.ReviewCreateResponse; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.Stability; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(ReviewController.class) +@TestPropertySource(locations = "classpath:application-test.properties") +public class ReviewControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ReviewService reviewService; + + @Autowired + private ObjectMapper objectMapper; + + // RE-01: 5단계 특성을 포함한 신발 리뷰 작성 API 테스트 + @Test + @DisplayName("Given 5-level characteristics request_When post review_Then return 201 with all levels") + void given5LevelCharacteristicsRequest_whenPostReview_thenReturn201WithAllLevels() throws Exception { + // given + ReviewCreateRequest request = ReviewCreateRequest.builder() + .content("정말 편한 신발입니다. 사이즈 딱 맞고 쿠션 매우 부드럽고 안정감 최고예요") + .rating(5) + .userId(1L) + .productId(1L) + .sizeFit(SizeFit.PERFECT) // 딱 맞음 + .cushioning(Cushion.VERY_SOFT) // 매우 부드러움 + .stability(Stability.VERY_STABLE) // 매우 안정적 + .build(); + + ReviewCreateResponse response = ReviewCreateResponse.builder() + .reviewId(1L) + .content(request.getContent()) + .rating(5) + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .build(); + + when(reviewService.createReview(any(ReviewCreateRequest.class))).thenReturn(response); + + // when & then + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.reviewId").value(1)) + .andExpect(jsonPath("$.sizeFit").value("PERFECT")) + .andExpect(jsonPath("$.cushioning").value("VERY_SOFT")) + .andExpect(jsonPath("$.stability").value("VERY_STABLE")); + + verify(reviewService, times(1)).createReview(any(ReviewCreateRequest.class)); + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/presentation/ReviewProductControllerTest.java b/src/test/java/com/cMall/feedShop/review/presentation/ReviewProductControllerTest.java new file mode 100644 index 000000000..d2e8b63bc --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/presentation/ReviewProductControllerTest.java @@ -0,0 +1,145 @@ +package com.cMall.feedShop.review.presentation; + +import com.cMall.feedShop.review.application.ReviewService; +import com.cMall.feedShop.review.application.ReviewStatisticsService; +import com.cMall.feedShop.review.application.dto.response.ProductReviewSummaryResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewStatisticsResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewSummaryResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.List; +import java.util.ArrayList; + +@WebMvcTest(ReviewProductController.class) +@TestPropertySource(locations = "classpath:application-test.properties") +public class ReviewProductControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ReviewService reviewService; + + @MockBean + private ReviewStatisticsService reviewStatisticsService; + + @Test + @DisplayName("Given product id_When get product review summary_Then return 200 ok") + void givenProductId_whenGetProductReviewSummary_thenReturn200Ok() throws Exception { + // given + Long productId = 1L; + + // 최근 리뷰 목록 생성 + List recentReviews = List.of( + ReviewSummaryResponse.builder() + .reviewId(1L) + .userId(1L) + .productId(productId) + .reviewTitle("정말 좋아요!") + .content("최고예요!") + .rating(5) + .createdAt(LocalDateTime.now()) + .images(new ArrayList<>()) + .build(), + ReviewSummaryResponse.builder() + .reviewId(2L) + .userId(2L) + .productId(productId) + .reviewTitle("편해요") + .content("편해요") + .rating(4) + .createdAt(LocalDateTime.now()) + .images(new ArrayList<>()) + .build() + ); + + // 평점 분포 생성 + ProductReviewSummaryResponse.RatingDistribution ratingDistribution = + ProductReviewSummaryResponse.RatingDistribution.builder() + .fiveStar(15L) + .fourStar(8L) + .threeStar(2L) + .twoStar(0L) + .oneStar(0L) + .build(); + + ProductReviewSummaryResponse response = ProductReviewSummaryResponse.builder() + .productId(productId) + .averageRating(4.5) + .totalReviews(25L) + .ratingDistribution(ratingDistribution) + .mostCommonSizeFit("PERFECT") + .recentReviews(recentReviews) + .build(); + + when(reviewStatisticsService.getProductReviewSummary(productId)).thenReturn(response); + + // when & then + mockMvc.perform(get("/api/products/{productId}/reviews/summary", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.productId").value(productId)) + .andExpect(jsonPath("$.averageRating").value(4.5)) + .andExpect(jsonPath("$.totalReviews").value(25)) + .andExpect(jsonPath("$.mostCommonSizeFit").value("PERFECT")) + .andExpect(jsonPath("$.recentReviews.length()").value(2)); + + verify(reviewStatisticsService, times(1)).getProductReviewSummary(productId); + } + + @Test + @DisplayName("Given product id_When get review statistics_Then return 200 ok") + void givenProductId_whenGetReviewStatistics_thenReturn200Ok() throws Exception { + // given + Long productId = 1L; + ReviewStatisticsResponse response = ReviewStatisticsResponse.builder() + .productId(productId) + .averageRating(4.2) + .totalReviews(50L) + .ratingDistribution(Map.of(5, 20L, 4, 15L, 3, 10L, 2, 3L, 1, 2L)) + .sizeFitDistribution(Map.of("PERFECT", 30L, "BIG", 15L, "SMALL", 5L)) + .stabilityDistribution(Map.of("VERY_STABLE", 25L, "STABLE", 20L, "NORMAL", 5L)) + .build(); + + when(reviewStatisticsService.getProductStatistics(productId)).thenReturn(response); + + // when & then + mockMvc.perform(get("/api/products/{productId}/reviews/statistics", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.productId").value(productId)) + .andExpect(jsonPath("$.averageRating").value(4.2)) + .andExpect(jsonPath("$.totalReviews").value(50)) + .andExpect(jsonPath("$.ratingDistribution.5").value(20)) + .andExpect(jsonPath("$.sizeFitDistribution.PERFECT").value(30)) + .andExpect(jsonPath("$.stabilityDistribution.VERY_STABLE").value(25)); + + verify(reviewStatisticsService, times(1)).getProductStatistics(productId); + } + + @Test + @DisplayName("Given invalid product id_When get product summary_Then return 404") + void givenInvalidProductId_whenGetProductSummary_thenReturn404() throws Exception { + // given + Long invalidProductId = 999L; + + when(reviewStatisticsService.getProductReviewSummary(invalidProductId)) + .thenThrow(new IllegalArgumentException("상품을 찾을 수 없습니다.")); + + // when & then + mockMvc.perform(get("/api/products/{productId}/reviews/summary", invalidProductId)) + .andExpect(status().isNotFound()); + + verify(reviewStatisticsService, times(1)).getProductReviewSummary(invalidProductId); + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/presentation/ReviewUserControllerTest.java b/src/test/java/com/cMall/feedShop/review/presentation/ReviewUserControllerTest.java new file mode 100644 index 000000000..30fa2d8bc --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/presentation/ReviewUserControllerTest.java @@ -0,0 +1,181 @@ +package com.cMall.feedShop.review.presentation; + +import com.cMall.feedShop.review.application.ReviewService; +import com.cMall.feedShop.review.application.dto.request.ReviewCreateRequest; +import com.cMall.feedShop.review.application.dto.response.ReviewCreateResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewDetailResponse; +import com.cMall.feedShop.review.application.dto.response.ReviewSummaryResponse; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.Stability; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.ArrayList; + +@WebMvcTest(ReviewUserController.class) +@TestPropertySource(locations = "classpath:application-test.properties") +public class ReviewUserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ReviewService reviewService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("Given user id_When get user reviews_Then return review list") + void givenUserId_whenGetUserReviews_thenReturnReviewList() throws Exception { + // given + Long userId = 1L; + List responses = List.of( + ReviewDetailResponse.builder() + .reviewId(1L) + .productId(1L) + .userId(userId) + .userName("사용자1") + .reviewTitle("좋은 상품입니다") + .rating(5) + .content("정말 만족스러운 구매였습니다") + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .imageUrls(new ArrayList<>()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(), + ReviewDetailResponse.builder() + .reviewId(2L) + .productId(2L) + .userId(userId) + .userName("사용자1") + .reviewTitle("괜찮은 상품") + .rating(4) + .content("전반적으로 만족합니다") + .sizeFit(SizeFit.BIG) + .cushioning(Cushion.SOFT) + .stability(Stability.STABLE) + .imageUrls(new ArrayList<>()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build() + ); + + Page pageResponse = new PageImpl<>(responses, PageRequest.of(0, 10), responses.size()); + when(reviewService.getUserReviews(eq(userId), any())).thenReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/users/{userId}/reviews", userId) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.content[0].reviewId").value(1)) + .andExpect(jsonPath("$.content[0].rating").value(5)) + .andExpect(jsonPath("$.content[1].reviewId").value(2)) + .andExpect(jsonPath("$.content[1].rating").value(4)); + + verify(reviewService, times(1)).getUserReviews(eq(userId), any()); + } + + @Test + @DisplayName("Given review data_When create review_Then return created review") + void givenReviewData_whenCreateReview_thenReturnCreatedReview() throws Exception { + // given + ReviewCreateRequest request = ReviewCreateRequest.builder() + .userId(1L) + .productId(1L) + .reviewTitle("훌륭한 상품!") + .rating(5) + .content("정말 좋은 상품입니다. 추천해요!") + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .imageUrls(new ArrayList<>()) + .build(); + + ReviewCreateResponse response = ReviewCreateResponse.builder() + .reviewId(1L) + .productId(1L) + .userId(1L) + .reviewTitle("훌륭한 상품!") + .rating(5) + .content("정말 좋은 상품입니다. 추천해요!") + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .imageUrls(new ArrayList<>()) + .createdAt(LocalDateTime.now()) + .build(); + + when(reviewService.createReview(any(ReviewCreateRequest.class))).thenReturn(response); + + // when & then + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.reviewId").value(1)) + .andExpect(jsonPath("$.rating").value(5)) + .andExpect(jsonPath("$.reviewTitle").value("훌륭한 상품!")); + + verify(reviewService, times(1)).createReview(any(ReviewCreateRequest.class)); + } +/* + @Test + @DisplayName("Given review id_When delete review_Then return no content") + void givenReviewId_whenDeleteReview_thenReturnNoContent() throws Exception { + // given + Long userId = 1L; + Long reviewId = 1L; + + doNothing().when(reviewService).deleteReview(userId, reviewId); + + // when & then + mockMvc.perform(delete("/api/reviews/{reviewId}", reviewId) + .param("userId", userId.toString())) + .andExpect(status().isNoContent()); + + verify(reviewService, times(1)).deleteReview(userId, reviewId); + } +*/ + @Test + @DisplayName("Given invalid user id_When get user reviews_Then return empty list") + void givenInvalidUserId_whenGetUserReviews_thenReturnEmptyList() throws Exception { + // given + Long invalidUserId = 999L; + Page emptyPage = new PageImpl<>(new ArrayList<>(), PageRequest.of(0, 10), 0); + + when(reviewService.getUserReviews(eq(invalidUserId), any())).thenReturn(emptyPage); + + // when & then + mockMvc.perform(get("/api/users/{userId}/reviews", invalidUserId) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(0)) + .andExpect(jsonPath("$.totalElements").value(0)); + + verify(reviewService, times(1)).getUserReviews(eq(invalidUserId), any()); + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/review/validation/ReviewValidationTest.java b/src/test/java/com/cMall/feedShop/review/validation/ReviewValidationTest.java new file mode 100644 index 000000000..836c832b4 --- /dev/null +++ b/src/test/java/com/cMall/feedShop/review/validation/ReviewValidationTest.java @@ -0,0 +1,438 @@ +package com.cMall.feedShop.review.validation; + +import com.cMall.feedShop.review.application.dto.request.ReviewCreateRequest; +import com.cMall.feedShop.review.application.dto.request.ReviewUpdateRequest; +import com.cMall.feedShop.review.domain.entity.SizeFit; +import com.cMall.feedShop.review.domain.entity.Cushion; +import com.cMall.feedShop.review.domain.entity.Stability; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@AutoConfigureWebMvc +@ActiveProfiles("test") +@Transactional +class ReviewValidationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private Validator validator; + + private ReviewCreateRequest validRequest; + + @BeforeEach + void setUp() { + validRequest = ReviewCreateRequest.builder() + .userId(1L) + .productId(1L) + .reviewTitle("유효한 리뷰 제목") + .rating(5) + .content("유효한 리뷰 내용입니다.") + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .imageUrls(new ArrayList<>()) + .build(); + } + + @Test + @DisplayName("Validation: 필수 필드 누락 검증") + void validateRequiredFields() throws Exception { + // 1. userId 누락 + ReviewCreateRequest noUserIdRequest = ReviewCreateRequest.builder() + .productId(1L) + .reviewTitle("제목") + .rating(5) + .content("내용") + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .imageUrls(new ArrayList<>()) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(noUserIdRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(containsString("사용자 ID는 필수입니다"))) + .andDo(print()); + + // 2. productId 누락 + ReviewCreateRequest noProductIdRequest = ReviewCreateRequest.builder() + .userId(1L) + .reviewTitle("제목") + .rating(5) + .content("내용") + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .imageUrls(new ArrayList<>()) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(noProductIdRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(containsString("상품 ID는 필수입니다"))) + .andDo(print()); + + // 3. rating 누락 + ReviewCreateRequest noRatingRequest = ReviewCreateRequest.builder() + .userId(1L) + .productId(1L) + .reviewTitle("제목") + .content("내용") + .sizeFit(SizeFit.PERFECT) + .cushioning(Cushion.VERY_SOFT) + .stability(Stability.VERY_STABLE) + .imageUrls(new ArrayList<>()) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(noRatingRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(containsString("평점은 필수입니다"))) + .andDo(print()); + } + + @Test + @DisplayName("Validation: 평점 범위 검증 (1-5점)") + void validateRatingRange() throws Exception { + // 1. 평점이 0인 경우 + ReviewCreateRequest zeroRatingRequest = validRequest.toBuilder() + .rating(0) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(zeroRatingRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(containsString("평점은 1점 이상이어야 합니다"))) + .andDo(print()); + + // 2. 평점이 6인 경우 + ReviewCreateRequest sixRatingRequest = validRequest.toBuilder() + .rating(6) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(sixRatingRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(containsString("평점은 5점 이하여야 합니다"))) + .andDo(print()); + + // 3. 음수 평점 + ReviewCreateRequest negativeRatingRequest = validRequest.toBuilder() + .rating(-1) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(negativeRatingRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(containsString("평점은 1점 이상이어야 합니다"))) + .andDo(print()); + } + + @Test + @DisplayName("Validation: 텍스트 길이 제한 검증") + void validateTextLengthLimits() throws Exception { + // 1. 리뷰 제목 길이 초과 (100자 초과) + String longTitle = "이것은 매우 긴 리뷰 제목입니다. ".repeat(10); // 100자 초과 + ReviewCreateRequest longTitleRequest = validRequest.toBuilder() + .reviewTitle(longTitle) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(longTitleRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(containsString("리뷰 제목은 100자를 초과할 수 없습니다"))) + .andDo(print()); + + // 2. 리뷰 내용 길이 초과 (1000자 초과) + String longContent = "이것은 매우 긴 리뷰 내용입니다. ".repeat(50); // 1000자 초과 + ReviewCreateRequest longContentRequest = validRequest.toBuilder() + .content(longContent) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(longContentRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(containsString("리뷰 내용은 1000자를 초과할 수 없습니다"))) + .andDo(print()); + } + + @Test + @DisplayName("Validation: 이미지 URL 개수 제한 검증") + void validateImageUrlLimit() throws Exception { + // 6개의 이미지 URL (5개 초과) + List tooManyImageUrls = List.of( + "http://example.com/image1.jpg", + "http://example.com/image2.jpg", + "http://example.com/image3.jpg", + "http://example.com/image4.jpg", + "http://example.com/image5.jpg", + "http://example.com/image6.jpg" + ); + + ReviewCreateRequest tooManyImagesRequest = validRequest.toBuilder() + .imageUrls(tooManyImageUrls) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(tooManyImagesRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(containsString("이미지는 최대 5개까지 업로드 가능합니다"))) + .andDo(print()); + } + + @Test + @DisplayName("Validation: 양수 값 검증 (ID 필드들)") + void validatePositiveValues() throws Exception { + // 1. 음수 userId + ReviewCreateRequest negativeUserIdRequest = validRequest.toBuilder() + .userId(-1L) + .build(); + + Set> violations = validator.validate(negativeUserIdRequest); + assertFalse(violations.isEmpty()); + assertTrue(violations.stream().anyMatch(v -> v.getMessage().contains("사용자 ID는 양수여야 합니다"))); + + // 2. 0인 productId + ReviewCreateRequest zeroProductIdRequest = validRequest.toBuilder() + .productId(0L) + .build(); + + violations = validator.validate(zeroProductIdRequest); + assertFalse(violations.isEmpty()); + assertTrue(violations.stream().anyMatch(v -> v.getMessage().contains("상품 ID는 양수여야 합니다"))); + } + + @Test + @DisplayName("Validation: enum 값 검증") + void validateEnumValues() throws Exception { + // 잘못된 JSON으로 enum 검증 (직접 JSON 문자열 사용) + String invalidEnumJson = """ + { + "userId": 1, + "productId": 1, + "reviewTitle": "테스트 리뷰", + "rating": 5, + "content": "테스트 내용", + "sizeFit": "INVALID_SIZE", + "cushioning": "VERY_SOFT", + "stability": "VERY_STABLE", + "imageUrls": [] + } + """; + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(invalidEnumJson)) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + + @Test + @DisplayName("Validation: 경계값 테스트") + void validateBoundaryValues() throws Exception { + // 1. 최소 유효 평점 (1점) + ReviewCreateRequest minRatingRequest = validRequest.toBuilder() + .rating(1) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(minRatingRequest))) + .andExpect(status().isCreated()) + .andDo(print()); + + // 2. 최대 유효 평점 (5점) + ReviewCreateRequest maxRatingRequest = validRequest.toBuilder() + .rating(5) + .userId(2L) // 중복 방지를 위해 다른 사용자 + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(maxRatingRequest))) + .andExpect(status().isCreated()) + .andDo(print()); + + // 3. 최대 길이 제목 (100자 정확히) + String maxLengthTitle = "a".repeat(100); + ReviewCreateRequest maxTitleRequest = validRequest.toBuilder() + .reviewTitle(maxLengthTitle) + .userId(3L) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(maxTitleRequest))) + .andExpect(status().isCreated()) + .andDo(print()); + + // 4. 최대 개수 이미지 (5개 정확히) + List maxImageUrls = List.of( + "http://example.com/image1.jpg", + "http://example.com/image2.jpg", + "http://example.com/image3.jpg", + "http://example.com/image4.jpg", + "http://example.com/image5.jpg" + ); + + ReviewCreateRequest maxImagesRequest = validRequest.toBuilder() + .imageUrls(maxImageUrls) + .userId(4L) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(maxImagesRequest))) + .andExpect(status().isCreated()) + .andDo(print()); + } + + @Test + @DisplayName("Validation: 업데이트 요청 검증") + void validateUpdateRequest() throws Exception { + // 유효한 리뷰 먼저 생성 + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isCreated()); + + // 잘못된 업데이트 요청들 + + // 1. 평점 범위 초과 + ReviewUpdateRequest invalidRatingUpdate = ReviewUpdateRequest.builder() + .rating(10) + .build(); + + Set> violations = validator.validate(invalidRatingUpdate); + assertFalse(violations.isEmpty()); + assertTrue(violations.stream().anyMatch(v -> v.getMessage().contains("평점은 5점 이하여야 합니다"))); + + // 2. 제목 길이 초과 + String longTitle = "a".repeat(101); + ReviewUpdateRequest longTitleUpdate = ReviewUpdateRequest.builder() + .reviewTitle(longTitle) + .build(); + + violations = validator.validate(longTitleUpdate); + assertFalse(violations.isEmpty()); + assertTrue(violations.stream().anyMatch(v -> v.getMessage().contains("리뷰 제목은 100자를 초과할 수 없습니다"))); + } + + @Test + @DisplayName("Validation: 빈 값과 null 값 처리") + void validateEmptyAndNullValues() throws Exception { + // 1. 빈 문자열 제목 + ReviewCreateRequest emptyTitleRequest = validRequest.toBuilder() + .reviewTitle("") + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(emptyTitleRequest))) + .andExpect(status().isCreated()) // 빈 제목은 허용 (선택적 필드) + .andDo(print()); + + // 2. null 제목 (선택적 필드이므로 허용) + ReviewCreateRequest nullTitleRequest = validRequest.toBuilder() + .reviewTitle(null) + .userId(2L) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(nullTitleRequest))) + .andExpect(status().isCreated()) + .andDo(print()); + + // 3. 빈 이미지 리스트 (허용) + ReviewCreateRequest emptyImageListRequest = validRequest.toBuilder() + .imageUrls(Collections.emptyList()) + .userId(3L) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(emptyImageListRequest))) + .andExpect(status().isCreated()) + .andDo(print()); + } + + @Test + @DisplayName("Validation: 특수 문자 및 유니코드 처리") + void validateSpecialCharactersAndUnicode() throws Exception { + // 1. 특수 문자가 포함된 제목 + ReviewCreateRequest specialCharsRequest = validRequest.toBuilder() + .reviewTitle("특수문자 테스트! @#$%^&*()_+-={}[]|\\:;\"'<>?,./") + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(specialCharsRequest))) + .andExpect(status().isCreated()) + .andDo(print()); + + // 2. 유니코드 문자 (이모지 포함) + ReviewCreateRequest unicodeRequest = validRequest.toBuilder() + .reviewTitle("완벽한 신발 😍👟✨") + .content("정말 좋아요! 💯 추천합니다 👍") + .userId(2L) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(unicodeRequest))) + .andExpect(status().isCreated()) + .andDo(print()); + + // 3. 다양한 언어 (한글, 영어, 일본어, 중국어) + ReviewCreateRequest multiLanguageRequest = validRequest.toBuilder() + .reviewTitle("Perfect shoes 完璧な靴 完美的鞋子") + .content("한글 English 日本語 中文 모두 지원되는지 테스트") + .userId(3L) + .build(); + + mockMvc.perform(post("/api/reviews") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(multiLanguageRequest))) + .andExpect(status().isCreated()) + .andDo(print()); + } +} \ No newline at end of file diff --git a/src/test/java/com/cMall/feedShop/user/application/service/UserAuthServiceTest.java b/src/test/java/com/cMall/feedShop/user/application/service/UserAuthServiceTest.java new file mode 100644 index 000000000..09c4cb074 --- /dev/null +++ b/src/test/java/com/cMall/feedShop/user/application/service/UserAuthServiceTest.java @@ -0,0 +1,151 @@ +package com.cMall.feedShop.user.application.service; + +import com.cMall.feedShop.common.exception.BusinessException; +import com.cMall.feedShop.common.exception.ErrorCode; +import com.cMall.feedShop.user.application.dto.request.UserLoginRequest; +import com.cMall.feedShop.user.application.dto.response.UserLoginResponse; +import com.cMall.feedShop.user.domain.enums.UserRole; +import com.cMall.feedShop.user.domain.model.User; +import com.cMall.feedShop.user.domain.repository.UserRepository; +import com.cMall.feedShop.user.infrastructure.security.JwtTokenProvider; +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.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; // PasswordEncoder mock은 AuthenticationManager 내부에서 사용되므로 직접 필요없지만, 생성자에 있다면 Mock으로 주입 +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ActiveProfiles("test") +@ExtendWith(MockitoExtension.class) // JUnit 5에서 Mockito를 사용하기 위한 설정 +class UserAuthServiceTest { + + @Mock // Mock 객체 생성 + private UserRepository userRepository; + + @Mock // Mock 객체 생성 + private PasswordEncoder passwordEncoder; // UserAuthService의 생성자에 있다면 Mock으로 필요 + + @Mock // Mock 객체 생성 + private JwtTokenProvider jwtTokenProvider; + + @Mock // Mock 객체 생성 + private AuthenticationManager authenticationManager; + + @InjectMocks // Mock 객체들을 주입받을 테스트 대상 서비스 + private UserAuthService userAuthService; + + // 테스트에 사용될 공통 데이터 + private UserLoginRequest loginRequest; + private User testUser; + private String dummyToken; + + @BeforeEach // 각 테스트 메서드 실행 전에 초기화 + void setUp() { + loginRequest = new UserLoginRequest(); + loginRequest.setEmail("test@example.com"); + loginRequest.setPassword("password123"); // 평문 비밀번호 + + testUser = new User( + "testLoginId", + "encodedPassword123", // DB에 저장된 암호화된 비밀번호 + "test@example.com", + "010-1234-5678", + UserRole.ROLE_USER + ); + // 테스트 객체 생성 시에는 생략하거나 mock 데이터를 직접 설정 + testUser.setId(1L); + testUser.setCreatedAt(LocalDateTime.now()); + testUser.setUpdatedAt(LocalDateTime.now()); + testUser.setPasswordChangedAt(LocalDateTime.now()); + + dummyToken = "dummy_jwt_token"; + } + + @Test + @DisplayName("성공적인 로그인 - JWT 토큰 발급 확인") + void login_success_returnsToken() { + // given (준비): Mock 객체의 행동 정의 + Authentication mockAuthentication = mock(Authentication.class); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenReturn(mockAuthentication); + + when(userRepository.findByEmail(loginRequest.getEmail())) + .thenReturn(Optional.of(testUser)); + + when(jwtTokenProvider.generateAccessToken(testUser.getEmail(), testUser.getRole().name())) + .thenReturn(dummyToken); + + // when (실행): 테스트 대상 메서드 호출 + UserLoginResponse response = userAuthService.login(loginRequest); + + // then (검증): 결과 확인 + assertNotNull(response); // 응답이 null이 아닌지 확인 + assertEquals(testUser.getLoginId(), response.getLoginId()); // 로그인 ID 일치 확인 + assertEquals(testUser.getRole(), response.getRole()); // 역할 일치 확인 + assertEquals(dummyToken, response.getToken()); // 토큰 일치 확인 + + // Mock 객체의 메서드가 예상대로 호출되었는지 검증 + verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(userRepository, times(1)).findByEmail(loginRequest.getEmail()); + verify(jwtTokenProvider, times(1)).generateAccessToken(testUser.getEmail(), testUser.getRole().name()); + } + + @Test + @DisplayName("로그인 실패 - 존재하지 않는 회원 (이메일 없음)") + void login_fail_userNotFound() { + // given: AuthenticationManager가 UsernameNotFoundException을 던지도록 설정 + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new UsernameNotFoundException("User not found with email: " + loginRequest.getEmail())); + + // when & then: BusinessException이 예상대로 발생하는지 검증 + BusinessException thrown = assertThrows(BusinessException.class, () -> { + userAuthService.login(loginRequest); + }); + + assertEquals(ErrorCode.USER_NOT_FOUND, thrown.getErrorCode()); + assertEquals("존재하지 않는 회원입니다.", thrown.getMessage()); + + // Mock 객체 호출 검증 + verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(userRepository, never()).findByEmail(anyString()); // 사용자를 찾지 못했으므로 호출되지 않음 + verify(jwtTokenProvider, never()).generateAccessToken(anyString(), anyString()); // 토큰 생성도 호출되지 않음 + } + + @Test + @DisplayName("로그인 실패 - 비밀번호 불일치") + void login_fail_passwordMismatch() { + // given: AuthenticationManager가 BadCredentialsException을 던지도록 설정 + // (비밀번호 불일치 시 발생) + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new BadCredentialsException("Bad credentials")); + + // when & then: BusinessException이 예상대로 발생하는지 검증 + BusinessException thrown = assertThrows(BusinessException.class, () -> { + userAuthService.login(loginRequest); + }); + + assertEquals(ErrorCode.UNAUTHORIZED, thrown.getErrorCode()); + assertEquals("이메일 또는 비밀번호가 올바르지 않습니다.", thrown.getMessage()); + + // Mock 객체 호출 검증 + verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(userRepository, never()).findByEmail(anyString()); // 인증 실패로 User 조회가 진행되지 않음 + verify(jwtTokenProvider, never()).generateAccessToken(anyString(), anyString()); // 토큰 생성도 호출되지 않음 + } +} diff --git a/src/test/java/com/cMall/shopChat/ShopChatApplicationTests.java b/src/test/java/com/cMall/shopChat/ShopChatApplicationTests.java deleted file mode 100644 index d666050b2..000000000 --- a/src/test/java/com/cMall/shopChat/ShopChatApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.cMall.shopChat; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class ShopChatApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/resources/application-integration.properties b/src/test/resources/application-integration.properties new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 000000000..bcfa4c991 --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,24 @@ +# ???? H2 ?????? ?? +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +# JPA ?? +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect + +# SSL ???? +server.ssl.enabled=false +server.port=0 + +# ?? ?? (????) +spring.mail.host=localhost +spring.mail.port=25 +spring.mail.username=test +spring.mail.password=test + +# ?? ?? +logging.level.org.springframework.test=DEBUG +logging.level.org.hibernate.SQL=DEBUG \ No newline at end of file diff --git a/src/test/resources/application-unit.properties b/src/test/resources/application-unit.properties new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/data/sample-data.json b/src/test/resources/data/sample-data.json new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/data/test-reviews.sql b/src/test/resources/data/test-reviews.sql new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/data/test-users.sql b/src/test/resources/data/test-users.sql new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/static/test-image.jpg b/src/test/resources/static/test-image.jpg new file mode 100644 index 000000000..e69de29bb