diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 9930fb483..000000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..2047096d6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +/.gradle +/build +/target + +# IntelliJ IDEA +*.iml +.idea +*.iws +*.ipr +out/ +.vscode/ + +# OS +.DS_Store + +# 로그 파일 +*.log + +.gitignore +.git +README.md +Dockerfile +.dockerignore + +# 환경 설정 파일 +.env +discodeit.env + +# 임시 파일 +*.tmp +*.swp \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..8a5622665 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,137 @@ +name: CD Test + +on: + push: + branches: [ "release" ] + +env: + AWS_REGION_PUBLIC: us-east-1 + AWS_REGION: ${{ secrets.AWS_REGION }} + ECR_REPOSITORY_URI: ${{ secrets.ECR_REPOSITORY_URI }} + ECR_REPOSITORY: discodeit + ECS_CLUSTER: ${{ secrets.ECS_CLUSTER }} + ECS_SERVICE: ${{ secrets.ECS_SERVICE }} + ECS_TASK_DEFINITION: ${{ secrets.ECS_TASK_DEFINITION }} + CONTAINER_NAME: discodeit-app + IMAGE_TAG: ${{ github.sha }} + +jobs: + build-and-push: + name: Build and Docker image push + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: corretto + java-version: 17 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ${{ env.AWS_REGION_PUBLIC }} + + - name: Login to Amazon ECR public + id: login-ecr-public + uses: aws-actions/amazon-ecr-login@v2 + with: + registry-type: public + + - name: Build Docker image + id: build-image + run: | + chmod +x ./gradlew + ./gradlew bootJar + docker build -t ${{ env.ECR_REPOSITORY_URI }}:latest -t ${{ env.ECR_REPOSITORY_URI}}:${IMAGE_TAG} . + + - name: Delete old images (latest + dangling) + run: | + echo "Deleting 'latest' tagged image..." + aws ecr-public batch-delete-image \ + --repository-name ${{ env.ECR_REPOSITORY }} \ + --image-ids imageTag=latest || true + + echo "Finding dangling images (no tags)..." + dangling=$(aws ecr-public describe-images \ + --repository-name ${{ env.ECR_REPOSITORY }} \ + --query 'imageDetails[?imageTags==`null` || length(imageTags)==`0`].imageDigest' \ + --output json) + + echo "Dangling images: $dangling" + + if [ "$dangling" != "[]" ]; then + echo "$dangling" | jq -r '.[]' | while read digest; do + echo "Deleting image digest: $digest" + aws ecr-public batch-delete-image \ + --repository-name ${{ env.ECR_REPOSITORY }} \ + --image-ids imageDigest=$digest || true + done + else + echo "No dangling images to delete." + fi + + - name: Push Docker images + run: | + docker push $ECR_REPOSITORY_URI:latest + docker push $ECR_REPOSITORY_URI:${IMAGE_TAG} + + deploy: + name: Update ECS Service + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Update Task Definition + id: task-def + run: | + NEW_IMAGE="$ECR_REPOSITORY_URI:$IMAGE_TAG" + + # 현재 태스크 정의 JSON을 받아와서 새 이미지로 교체 + aws ecs describe-task-definition --task-definition ${{ env.ECS_TASK_DEFINITION }} \ + --query "taskDefinition" > taskdef.json + + # 새 이미지 태그로 교체 + jq --arg IMAGE "$NEW_IMAGE" '.containerDefinitions[0].image=$IMAGE' \ + taskdef.json > new-taskdef.json + + # 불필요한 필드 제거 + jq 'del(.status, .revision, .taskDefinitionArn, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)' \ + new-taskdef.json > final-taskdef.json + + # 새 태스크 정의 등록 + TASK_DEF_ARN=$(aws ecs register-task-definition --cli-input-json file://final-taskdef.json \ + --query "taskDefinition.taskDefinitionArn" --output text) + echo "TASK_DEF_ARN=$TASK_DEF_ARN" >> $GITHUB_ENV + + - name: Scale down ECS service (프리티어 리소스 고려) + run: | + echo "Scaling down ECS service to 0 tasks..." + aws ecs update-service \ + --cluster ${{ env.ECS_CLUSTER }} \ + --service ${{ env.ECS_SERVICE }} \ + --desired-count 0 + sleep 10 + + - name: Start ECS service + run: | + echo "Updating ECS service to new task definition..." + aws ecs update-service \ + --cluster ${{ env.ECS_CLUSTER }} \ + --service ${{ env.ECS_SERVICE }} \ + --task-definition $TASK_DEF_ARN \ + --desired-count 1 \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..e74d679c2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: CI Test + +on: + push: + branches: [ "한동우-sprint8" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: corretto + java-version: 17 + + - name: Install dependencies & run tests + run: ./gradlew clean test jacocoTestReport + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index d5a12dc8e..7827c7f73 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,22 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +## log 파일 +.logs/ + +## 로컬 바이너리 파일 +binaries/ + +## 환경 설정 파일 +discodeit.env +.env + +## AWS 관련 민감 파일들 +*.pem +*.key +aws-credentials.txt +.aws/ +credentials +config \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b81b..000000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 8bd18ecfa..000000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 2a65317ef..000000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index fe0b0daba..000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git "a/.idea/shelf/Uncommitted_changes_before_Checkout_at_2025__4__8__\354\230\244\355\233\204_2_25/shelved.patch" "b/.idea/shelf/Uncommitted_changes_before_Checkout_at_2025__4__8__\354\230\244\355\233\204_2_25/shelved.patch" deleted file mode 100644 index c9e39bd07..000000000 --- "a/.idea/shelf/Uncommitted_changes_before_Checkout_at_2025__4__8__\354\230\244\355\233\204_2_25/shelved.patch" +++ /dev/null @@ -1,37 +0,0 @@ -Index: .idea/vcs.xml -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP -<+>\n\n \n \n \n \n -=================================================================== -diff --git a/.idea/vcs.xml b/.idea/vcs.xml ---- a/.idea/vcs.xml (revision 03248a99442145718487c8661d0f332b6369cf4c) -+++ b/.idea/vcs.xml (date 1744089833326) -@@ -2,6 +2,5 @@ - - - -- - - -\ No newline at end of file -Index: .idea/misc.xml -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP -<+>\n\n \n \n \n \n \n \n \n -=================================================================== -diff --git a/.idea/misc.xml b/.idea/misc.xml ---- a/.idea/misc.xml (revision 03248a99442145718487c8661d0f332b6369cf4c) -+++ b/.idea/misc.xml (date 1744089833320) -@@ -1,10 +1,6 @@ - - -- -- -- -- -- -+ - - - -\ No newline at end of file diff --git "a/.idea/shelf/Uncommitted_changes_before_Checkout_at_2025__4__8__\354\230\244\355\233\204_2_251/shelved.patch" "b/.idea/shelf/Uncommitted_changes_before_Checkout_at_2025__4__8__\354\230\244\355\233\204_2_251/shelved.patch" deleted file mode 100644 index e69de29bb..000000000 diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddfb..000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..94e8c5cef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# 1. 빌드 +FROM amazoncorretto:17-alpine-jdk AS builder + +ENV PROJECT_NAME=3-sprint-mission +ENV PROJECT_VERSION=1.2-M8 + +WORKDIR /app + +# .dockerignore를 활용해 불필요한 파일 제외하고 복사 +COPY . . + +# 애플케이션 빌드 +RUN chmod +x gradlew +RUN ./gradlew bootJar + +# 2. 런타임 +# 환경 변수 설정 +FROM amazoncorretto:17-alpine + +ENV PROJECT_NAME=3-sprint-mission +ENV PROJECT_VERSION=1.2-M8 +ENV JVM_OPTS="" + +# 작업 디렉토리 설정 +WORKDIR /app + +# 빌드 스테이지에서 JAR 파일만 복사 +COPY --from=builder /app/build/libs/${PROJECT_NAME}-${PROJECT_VERSION}.jar app.jar + +# 80 포트 노출 +EXPOSE 80 + +# 애플리케이션 실행 +ENTRYPOINT ["sh", "-c", "java $JVM_OPTS -jar app.jar"] diff --git a/README.md b/README.md new file mode 100644 index 000000000..834fa03ef --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +[![codecov](https://codecov.io/gh/dw-real/3-sprint-mission/branch/한동우-sprint8/graph/badge.svg)](https://codecov.io/gh/dw-real/3-sprint-mission) diff --git a/build.gradle b/build.gradle index 9e54c4cd3..da87879ca 100644 --- a/build.gradle +++ b/build.gradle @@ -2,10 +2,11 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.0' id 'io.spring.dependency-management' version '1.1.7' + id 'jacoco' } group = 'com.sprint.mission' -version = '0.0.1-SNAPSHOT' +version = '1.2-M8' java { toolchain { @@ -19,6 +20,18 @@ configurations { } } +test { + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } +} + repositories { mavenCentral() } @@ -27,8 +40,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-aop' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.7' runtimeOnly 'org.postgresql:postgresql' @@ -36,8 +51,14 @@ dependencies { // https://mvnrepository.com/artifact/org.mapstruct/mapstruct implementation 'org.mapstruct:mapstruct:1.5.5.Final' annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' + // actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + // S3 + implementation 'software.amazon.awssdk:s3:2.31.7' } tasks.named('test') { useJUnitPlatform() + systemProperty "spring.profiles.active", "test" + finalizedBy jacocoTestReport } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..1ce3dad4c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,71 @@ +version: '3.8' + +# 서비스 정의 +services: + postgres: + image: postgres:17-alpine + container_name: discodeit-db + restart: unless-stopped + + # 환경변수 설정 + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C" + TZ: "Asia/Seoul" + + # 포트 매핑 + ports: + - "${POSTGRES_PORT}:5432" + + # 볼륨 마운트 (데이터 영속화) + volumes: + - postgres_data:/var/lib/postgresql/data + - ./src/main/resources/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro + + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ] + interval: 10s + timeout: 5s + retries: 5 + + app: + build: + context: . + dockerfile: Dockerfile + image: discodeit-app:local-slim + container_name: discodeit_app + env_file: .env + # 환경 변수 설정 + environment: + # STORAGE 설정 + STORAGE_TYPE: ${STORAGE_TYPE:-local} + STORAGE_LOCAL_ROOT_PATH: ${STORAGE_LOCAL_ROOT_PATH} + + # AWS S3 설정 + AWS_S3_ACCESS_KEY: ${AWS_S3_ACCESS_KEY} + AWS_S3_SECRET_KEY: ${AWS_S3_SECRET_KEY} + AWS_S3_REGION: ${AWS_S3_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + AWS_S3_PRESIGNED_URL_EXPIRATION: ${AWS_S3_PRESIGNED_URL_EXPIRATION:-600} + + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE} + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + DISCODEIT_STORAGE_LOCAL_ROOT_PATH: /app/binary_content_storage + + depends_on: + postgres: + condition: service_healthy + + volumes: + - app_data:/app/binary_content_storage + + ports: + - "${APP_PORT}:80" + +volumes: + postgres_data: + app_data: \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java index d628b0d64..2ac54d9f3 100644 --- a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java +++ b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java @@ -2,13 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -@EnableJpaAuditing @SpringBootApplication public class DiscodeitApplication { - public static void main(String[] args) { - SpringApplication.run(DiscodeitApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(DiscodeitApplication.class, args); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/aspect/LoggingAspect.java b/src/main/java/com/sprint/mission/discodeit/aspect/LoggingAspect.java new file mode 100644 index 000000000..10f038213 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/aspect/LoggingAspect.java @@ -0,0 +1,163 @@ +package com.sprint.mission.discodeit.aspect; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.*; +import org.springframework.stereotype.Component; + +import java.util.Arrays; + +@Aspect +@Component +@Slf4j +public class LoggingAspect { + + @Pointcut("execution(* com.sprint.mission.discodeit.service.basic.BasicUserService.*(..))" + + "|| execution(* com.sprint.mission.discodeit.service.basic.BasicChannelService.*(..))" + + "|| execution(* com.sprint.mission.discodeit.service.basic.BasicMessageService.*(..))") + public void serviceLayer() { + } + + @Pointcut("execution(* com.sprint.mission.discodeit.controller.UserController.*(..))" + + "|| execution(* com.sprint.mission.discodeit.controller.ChannelController.*.*(..))" + + "|| execution(* com.sprint.mission.discodeit.controller.MessageController.*(..))") + public void controllerLayer() { + } + + /** + * 메서드 실행 전에 로그를 기록한다. + * + * @param joinPoint 조인포인트 정보 + */ + @Before("serviceLayer() || controllerLayer()") + public void logBefore(JoinPoint joinPoint) { + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + Object[] args = joinPoint.getArgs(); + + log.info("{}.{} 실행 시작 | 매개변수: {}", + className, methodName, Arrays.toString(args)); + } + + /** + * 메서드 정상 실행 후에 로그를 기록한다. + * + * @param joinPoint 조인포인트 정보 + * @param result 메서드 반환값 + */ + @AfterReturning(pointcut = "serviceLayer() || controllerLayer()", returning = "result") + public void logAfterReturning(JoinPoint joinPoint, Object result) { + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + + log.info("{}.{} 실행 완료 | 반환값: {}", + className, methodName, result); + } + + /** + * 메서드 실행 중 예외 발생 시 로그를 기록한다. + * + * @param joinPoint 조인포인트 정보 + * @param exception 발생한 예외 + */ + @AfterThrowing(pointcut = "serviceLayer() || controllerLayer()", throwing = "exception") + public void logAfterThrowing(JoinPoint joinPoint, Exception exception) { + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + + log.error("!!! {}.{} 실행 중 예외 발생 - 예외: {}, 메시지: {}", + className, methodName, exception.getClass().getSimpleName(), exception.getMessage()); + } + + /** + * 메서드 실행 시간을 측정하고 로그를 기록한다. + * + * @param proceedingJoinPoint 프로시딩 조인포인트 + * @return 메서드 실행 결과 + * @throws Throwable 메서드 실행 중 발생한 예외 + */ + @Around("serviceLayer()") + public Object logExecutionTime(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { + String className = proceedingJoinPoint.getTarget().getClass().getSimpleName(); + String methodName = proceedingJoinPoint.getSignature().getName(); + + long startTime = System.currentTimeMillis(); + + try { + Object result = proceedingJoinPoint.proceed(); + long endTime = System.currentTimeMillis(); + long executionTime = endTime - startTime; + + log.info("{}.{} 실행 시간: {}ms", className, methodName, executionTime); + + // 성능 경고 (1초 이상 소요 시) + if (executionTime > 1000) { + log.warn("[s2][LoggingAspect] {}.{} 실행 시간이 {}ms로 느립니다. 성능 최적화가 필요합니다.", + className, methodName, executionTime); + } + + return result; + + } catch (Throwable throwable) { + long endTime = System.currentTimeMillis(); + long executionTime = endTime - startTime; + + log.error("{}.{} 실행 실패 | 실행 시간: {}ms, 예외: {}", + className, methodName, executionTime, throwable.getMessage()); + + throw throwable; + } + } + + /** + * 비즈니스 이벤트 로깅을 위한 포인트컷 + * 사용자, 채널, 메시지 생성/수정/삭제, 파일 업로드/다운로드 + */ + @Pointcut("execution(* com.sprint.mission.discodeit.service.basic.BasicUserService.create(..))" + + "|| execution(* com.sprint.mission.discodeit.service.basic.BasicUserService.update(..))" + + "|| execution(* com.sprint.mission.discodeit.service.basic.BasicUserService.deleteById(..))" + + + "|| execution(* com.sprint.mission.discodeit.service.basic.BasicChannelService.createPublicChannel(..))" + + "|| execution(* com.sprint.mission.discodeit.service.basic.BasicChannelService.createPrivateChannel(..))" + + "|| execution(* com.sprint.mission.discodeit.service.basic.BasicChannelService.update(..))" + + "|| execution(* com.sprint.mission.discodeit.service.basic.BasicChannelService.deleteById(..))" + + + "|| execution(* com.sprint.mission.discodeit.service.basic.BasicMessageService.create(..))" + + "|| execution(* com.sprint.mission.discodeit.service.basic.BasicMessageService.updateContent(..))" + + "|| execution(* com.sprint.mission.discodeit.service.basic.BasicMessageService.deleteById(..))" + + + "|| execution(* com.sprint.mission.discodeit.storage.LocalBinaryContentStorage.put(..))" + + "|| execution(* com.sprint.mission.discodeit.storage.LocalBinaryContentStorage.download(..))") + public void businessEvents() { + } + + /** + * 중요한 비즈니스 이벤트에 대한 상세 로깅을 수행한다. + * + * @param proceedingJoinPoint 프로시딩 조인포인트 + * @return 메서드 실행 결과 + * @throws Throwable 메서드 실행 중 발생한 예외 + */ + @Around("businessEvents()") + public Object logBusinessEvent(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { + String methodName = proceedingJoinPoint.getSignature().getName(); + Object[] args = proceedingJoinPoint.getArgs(); + + log.info("비즈니스 이벤트 시작 - 작업: {}, 매개변수: {}", methodName, Arrays.toString(args)); + + try { + Object result = proceedingJoinPoint.proceed(); + + log.info("비즈니스 이벤트 성공 - 작업: {}, 결과: {}", methodName, result); + + return result; + + } catch (Throwable throwable) { + log.error("비즈니스 이벤트 실패 - 작업: {}, 예외: {}, 메시지: {}", + methodName, throwable.getClass().getSimpleName(), throwable.getMessage()); + + throw throwable; + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/JpaConfig.java b/src/main/java/com/sprint/mission/discodeit/config/JpaConfig.java new file mode 100644 index 000000000..c2a73287d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/JpaConfig.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java new file mode 100644 index 000000000..c655dcb51 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java @@ -0,0 +1,39 @@ +package com.sprint.mission.discodeit.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.UUID; + +@Slf4j +@Component +public class MDCLoggingInterceptor implements HandlerInterceptor { + + private static final String REQUEST_ID = "requestId"; + private static final String METHOD = "method"; + private static final String URL = "url"; + private static final String HEADER_NAME = "Discodeit-Request-ID"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String requestId = UUID.randomUUID().toString().substring(0, 8); + String method = request.getMethod(); + String url = request.getRequestURI(); + + MDC.put(REQUEST_ID, requestId); + MDC.put(METHOD, method); + MDC.put(URL, url); + response.setHeader(HEADER_NAME, requestId); + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { + MDC.clear(); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java new file mode 100644 index 000000000..500f8f971 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java @@ -0,0 +1,18 @@ +package com.sprint.mission.discodeit.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final MDCLoggingInterceptor mdcLoggingInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(mdcLoggingInterceptor); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java index 35f86ebb5..c55433550 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -4,6 +4,7 @@ import com.sprint.mission.discodeit.dto.auth.LoginDto; import com.sprint.mission.discodeit.dto.user.UserResponseDto; import com.sprint.mission.discodeit.service.AuthService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -20,7 +21,7 @@ public class AuthController implements AuthApi { private final AuthService authService; @PostMapping(path = "/login") - public ResponseEntity login(@RequestBody LoginDto loginDTO) { + public ResponseEntity login(@Valid @RequestBody LoginDto loginDTO) { UserResponseDto loggedInUser = authService.login(loginDTO); return ResponseEntity.status(HttpStatus.OK).body(loggedInUser); diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java index e007006ec..5913e9d6d 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.UUID; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -32,7 +33,7 @@ public class ChannelController implements ChannelApi { @PostMapping(path = "/public") public ResponseEntity createPublicChannel( - @RequestBody PublicChannelDto publicChannelDTO) { + @Valid @RequestBody PublicChannelDto publicChannelDTO) { ChannelResponseDto createdChannel = channelService.createPublicChannel(publicChannelDTO); return ResponseEntity.status(HttpStatus.CREATED).body(createdChannel); diff --git a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java index f033e6d93..5aa2dcccc 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java @@ -8,6 +8,7 @@ import com.sprint.mission.discodeit.dto.response.PageResponse; import com.sprint.mission.discodeit.service.MessageService; import com.sprint.mission.discodeit.util.FileConverter; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -31,7 +32,7 @@ public class MessageController implements MessageApi { @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity create( - @RequestPart("messageCreateRequest") MessageRequestDto messageRequestDTO, + @Valid @RequestPart("messageCreateRequest") MessageRequestDto messageRequestDTO, @RequestPart(value = "attachments", required = false) List attachedFiles) { List binaryContentDtos = FileConverter.resolveFileRequest(attachedFiles); diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java index 5a344fad4..5a5b58584 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java @@ -5,6 +5,7 @@ import com.sprint.mission.discodeit.dto.readstatus.ReadStatusResponseDto; import com.sprint.mission.discodeit.dto.readstatus.ReadStatusUpdateDto; import com.sprint.mission.discodeit.service.ReadStatusService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -21,7 +22,7 @@ public class ReadStatusController implements ReadStatusApi { private final ReadStatusService readStatusService; @PostMapping - public ResponseEntity create(@RequestBody ReadStatusRequestDto readStatusRequestDTO) { + public ResponseEntity create(@Valid @RequestBody ReadStatusRequestDto readStatusRequestDTO) { ReadStatusResponseDto createdReadStatus = readStatusService.create(readStatusRequestDTO); return ResponseEntity.status(HttpStatus.CREATED).body(createdReadStatus); diff --git a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java index 17e3e0cb2..4c764a68a 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java @@ -10,6 +10,7 @@ import com.sprint.mission.discodeit.service.UserService; import com.sprint.mission.discodeit.service.UserStatusService; import com.sprint.mission.discodeit.util.FileConverter; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -43,7 +44,7 @@ public class UserController implements UserApi { // 신규 유저 생성 요청 @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity create( - @RequestPart("userCreateRequest") UserRequestDto userRequestDTO, + @Valid @RequestPart("userCreateRequest") UserRequestDto userRequestDTO, @RequestPart(value = "profile", required = false) MultipartFile profile) { BinaryContentDto profileRequest = FileConverter.resolveFileRequest(profile); diff --git a/src/main/java/com/sprint/mission/discodeit/dto/auth/LoginDto.java b/src/main/java/com/sprint/mission/discodeit/dto/auth/LoginDto.java index abe6cde5b..674080bd8 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/auth/LoginDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/auth/LoginDto.java @@ -1,8 +1,13 @@ package com.sprint.mission.discodeit.dto.auth; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; @Schema(description = "로그인 정보") -public record LoginDto(String username, String password) { +public record LoginDto( + @NotBlank(message = "사용자 이름은 필수 입력 항목입니다.") + String username, + @NotBlank(message = "비밀번호는 필수 입력 항목입니다.") + String password) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelDto.java b/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelDto.java index 2782a53a4..5ca5bf088 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelDto.java @@ -1,8 +1,16 @@ package com.sprint.mission.discodeit.dto.channel; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; @Schema(description = "Public Channel 생성 및 수정 정보") -public record PublicChannelDto(String name, String description) { +public record PublicChannelDto( + @NotBlank(message = "채널 이름은 필수 입력 항목입니다.") + @Size(max = 100, message = "채널 이름은 최대 100자까지 입력 가능합니다.") + String name, + + @Size(max = 500, message = "채널에 대한 설명은 최대 500자까지 입력 가능합니다.") + String description) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelUpdateDto.java b/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelUpdateDto.java index 00ad02193..d04466226 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelUpdateDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelUpdateDto.java @@ -1,5 +1,14 @@ package com.sprint.mission.discodeit.dto.channel; -public record PublicChannelUpdateDto(String newName, String newDescription) { +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PublicChannelUpdateDto( + @NotBlank(message = "채널 이름은 필수 입력 항목입니다.") + @Size(max = 100, message = "채널 이름은 최대 100자까지 입력 가능합니다.") + String newName, + + @Size(max = 500, message = "채널에 대한 설명은 최대 500자까지 입력 가능합니다.") + String newDescription) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/message/MessageRequestDto.java b/src/main/java/com/sprint/mission/discodeit/dto/message/MessageRequestDto.java index d78c4b66c..5f83915af 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/message/MessageRequestDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/message/MessageRequestDto.java @@ -1,9 +1,20 @@ package com.sprint.mission.discodeit.dto.message; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + import java.util.UUID; @Schema(description = "Message 생성 및 수정 정보") -public record MessageRequestDto(String content, UUID channelId, UUID authorId) { +public record MessageRequestDto( + @Size(max = 1000, message = "메시지는 1000자 이내여야 합니다.") + String content, + + @NotNull(message = "channelId는 필수입니다.") + UUID channelId, + + @NotNull(message = "작성자 ID는 필수입니다.") + UUID authorId) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/message/MessageUpdateDto.java b/src/main/java/com/sprint/mission/discodeit/dto/message/MessageUpdateDto.java index f8812475f..c6566bf97 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/message/MessageUpdateDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/message/MessageUpdateDto.java @@ -1,5 +1,9 @@ package com.sprint.mission.discodeit.dto.message; -public record MessageUpdateDto(String newContent) { +import jakarta.validation.constraints.Size; + +public record MessageUpdateDto( + @Size(max = 1000, message = "메시지는 1000자 이내여야 합니다.") + String newContent) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusRequestDto.java b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusRequestDto.java index d38c222b3..7b9788354 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusRequestDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusRequestDto.java @@ -1,9 +1,19 @@ package com.sprint.mission.discodeit.dto.readstatus; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + import java.time.Instant; import java.util.UUID; @Schema(description = "Message 읽음 상태 생성 및 수정 정보") -public record ReadStatusRequestDto(UUID userId, UUID channelId, Instant lastReadAt) { +public record ReadStatusRequestDto( + @NotNull(message = "사용자 ID는 필수입니다.") + UUID userId, + + @NotNull(message = "채널 ID는 필수입니다.") + UUID channelId, + + @NotNull(message = "마지막 읽음 시간은 필수입니다.") + Instant lastReadAt) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusUpdateDto.java b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusUpdateDto.java index e980d17b6..611e23118 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusUpdateDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusUpdateDto.java @@ -1,7 +1,11 @@ package com.sprint.mission.discodeit.dto.readstatus; +import jakarta.validation.constraints.NotNull; + import java.time.Instant; -public record ReadStatusUpdateDto(Instant newLastReadAt) { +public record ReadStatusUpdateDto( + @NotNull(message = "마지막 읽음 시간은 필수입니다.") + Instant newLastReadAt) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/ErrorResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/response/ErrorResponse.java new file mode 100644 index 000000000..e3597b58e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/ErrorResponse.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.dto.response; + +import java.time.Instant; +import java.util.Map; + +public record ErrorResponse(Instant timestamp, + String code, + String message, + Map details, + String exceptionType, + int status) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/user/UserRequestDto.java b/src/main/java/com/sprint/mission/discodeit/dto/user/UserRequestDto.java index 44d7aad8e..3a949e141 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/user/UserRequestDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/user/UserRequestDto.java @@ -1,8 +1,21 @@ package com.sprint.mission.discodeit.dto.user; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; @Schema(description = "User 생성 및 수정 정보") -public record UserRequestDto(String username, String email, String password) { +public record UserRequestDto( + @NotBlank(message = "이름은 필수 입력 항목입니다.") + @Size(max = 10, message = "이름은 최대 10자까지 입력 가능합니다.") + String username, + + @NotBlank(message = "이메일은 필수 입력 항목입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email, + + @Size(min = 4, max = 20, message = "비밀번호는 4자 이상 20자 이하로 입력해주세요") + String password) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/user/UserUpdateDto.java b/src/main/java/com/sprint/mission/discodeit/dto/user/UserUpdateDto.java index 1e731d943..bf9bca3d8 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/user/UserUpdateDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/user/UserUpdateDto.java @@ -1,5 +1,19 @@ package com.sprint.mission.discodeit.dto.user; -public record UserUpdateDto(String newUsername, String newEmail, String newPassword) { +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UserUpdateDto( + @NotBlank(message = "이름은 필수 입력 항목입니다.") + @Size(max = 10, message = "이름은 최대 10자까지 입력 가능합니다.") + String newUsername, + + @NotBlank(message = "이메일은 필수 입력 항목입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String newEmail, + + @Size(min = 4, max = 20, message = "비밀번호는 4자 이상 20자 이하로 입력해주세요") + String newPassword) { } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ErrorCode.java b/src/main/java/com/sprint/mission/discodeit/entity/ErrorCode.java new file mode 100644 index 000000000..88fc400bd --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/ErrorCode.java @@ -0,0 +1,26 @@ +package com.sprint.mission.discodeit.entity; + +import lombok.Getter; + +@Getter +public enum ErrorCode { + USER_NOT_FOUND("사용자를 찾을 수 없습니다"), + DUPLICATE_USER_NAME("이미 존재하는 Username입니다."), + DUPLICATE_USER_EMAIL("이미 존재하는 이메일입니다."), + CHANNEL_NOT_FOUND("채널을 찾을 수 없습니다."), + PRIVATE_CHANNEL_UPDATE("Private Channel은 수정할 수 없습니다."), + MESSAGE_NOT_FOUND("메시지를 찾을 수 없습니다."), + LOGIN_FAILED("로그인이 실패했습니다. 비밀번호를 확인해주세요."), + BINARY_CONTENT_NOT_FOUND("파일을 찾을 수 없습니다."), + READ_STATUS_NOT_FOUND("읽음 상태를 찾을 수 없습니다."), + USER_STATUS_NOT_FOUND("사용자 상태를 찾을 수 없습니다."), + READ_STATUS_ALREADY_EXIST("이미 존재하는 읽음 상태입니다."), + USER_STATUS_ALREADY_EXIST("이미 존재하는 사용자 상태입니다."), + INTERNAL_SERVER_ERROR("서버 내부 오류가 발생했습니다."); + + private final String message; + + ErrorCode(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java new file mode 100644 index 000000000..2ea0661fc --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.exception; + +import com.sprint.mission.discodeit.entity.ErrorCode; +import lombok.Getter; + +import java.time.Instant; +import java.util.Map; + +@Getter +public class DiscodeitException extends RuntimeException { + + private final Instant timestamp; + private final ErrorCode errorCode; + private final Map details; + + public DiscodeitException(String message, ErrorCode errorCode, Map details) { + super(message); + this.timestamp = Instant.now(); + this.errorCode = errorCode; + this.details = details; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/LoginFailedException.java b/src/main/java/com/sprint/mission/discodeit/exception/LoginFailedException.java deleted file mode 100644 index 731c69da4..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/LoginFailedException.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.sprint.mission.discodeit.exception; - -public class LoginFailedException extends RuntimeException { - - public LoginFailedException() { - super("비밀번호가 일치하지 않습니다."); - } - - public LoginFailedException(String message) { - super(message); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/PrivateChannelModificationException.java b/src/main/java/com/sprint/mission/discodeit/exception/PrivateChannelModificationException.java deleted file mode 100644 index 55defd07e..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/PrivateChannelModificationException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.sprint.mission.discodeit.exception; - -public class PrivateChannelModificationException extends RuntimeException { - public PrivateChannelModificationException() { - super("PRIVATE 채널은 변경이 불가능합니다."); - } - - public PrivateChannelModificationException(String message) { - super(message); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/AlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/AlreadyExistsException.java deleted file mode 100644 index 06fcd75db..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/AlreadyExistsException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.sprint.mission.discodeit.exception.alreadyexist; - -public class AlreadyExistsException extends RuntimeException { - public AlreadyExistsException(String message) { - super(message); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/ReadStatusAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/ReadStatusAlreadyExistsException.java deleted file mode 100644 index 219912343..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/ReadStatusAlreadyExistsException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.sprint.mission.discodeit.exception.alreadyexist; - -public class ReadStatusAlreadyExistsException extends RuntimeException { - public ReadStatusAlreadyExistsException() { - super("해당 User, Channel과 관련된 상태가 이미 존재합니다."); - } - - public ReadStatusAlreadyExistsException(String message) { - super(message); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/UserStatusAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/UserStatusAlreadyExistsException.java deleted file mode 100644 index 17d88e9c2..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/UserStatusAlreadyExistsException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.sprint.mission.discodeit.exception.alreadyexist; - -public class UserStatusAlreadyExistsException extends AlreadyExistsException { - public UserStatusAlreadyExistsException() { - super("해당 User와 관련된 UserStatus가 이미 존재합니다."); - } - - public UserStatusAlreadyExistsException(String message) { - super(message); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java new file mode 100644 index 000000000..2f81b4994 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.exception.binarycontent; + +import com.sprint.mission.discodeit.entity.ErrorCode; +import com.sprint.mission.discodeit.exception.DiscodeitException; + +import java.util.Map; + +public class BinaryContentException extends DiscodeitException { + + public BinaryContentException(String message, ErrorCode errorCode, Map details) { + super(message, errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/NotFoundBinaryContentException.java b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/NotFoundBinaryContentException.java new file mode 100644 index 000000000..70faab30d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/NotFoundBinaryContentException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.binarycontent; + +import com.sprint.mission.discodeit.entity.ErrorCode; + +import java.util.Map; +import java.util.UUID; + +public class NotFoundBinaryContentException extends BinaryContentException { + + public NotFoundBinaryContentException(UUID id) { + super( + ErrorCode.BINARY_CONTENT_NOT_FOUND.getMessage() + " 파일 id: " + id, + ErrorCode.BINARY_CONTENT_NOT_FOUND, + Map.of("binaryContentId", id) + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java new file mode 100644 index 000000000..80566ca3b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.entity.ErrorCode; +import com.sprint.mission.discodeit.exception.DiscodeitException; + +import java.util.Map; + +public class ChannelException extends DiscodeitException { + + public ChannelException(String message, ErrorCode errorCode, Map details) { + super(message, errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/NotFoundChannelException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/NotFoundChannelException.java new file mode 100644 index 000000000..e15933c61 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/NotFoundChannelException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.entity.ErrorCode; + +import java.util.Map; +import java.util.UUID; + +public class NotFoundChannelException extends ChannelException { + + public NotFoundChannelException(UUID channelId) { + super( + ErrorCode.CHANNEL_NOT_FOUND.getMessage() + " channelId: " + channelId, + ErrorCode.CHANNEL_NOT_FOUND, + Map.of("channelId", channelId) + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java new file mode 100644 index 000000000..f9cd00535 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.entity.ErrorCode; + +import java.util.Map; +import java.util.UUID; + +public class PrivateChannelUpdateException extends ChannelException { + + public PrivateChannelUpdateException(UUID channelId) { + super( + ErrorCode.PRIVATE_CHANNEL_UPDATE.getMessage(), + ErrorCode.PRIVATE_CHANNEL_UPDATE, + Map.of("channelId", "channelId") + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateEmailException.java b/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateEmailException.java deleted file mode 100644 index 685ca532b..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateEmailException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.sprint.mission.discodeit.exception.duplicate; - -public class DuplicateEmailException extends DuplicateException { - public DuplicateEmailException(String email) { - super(email + "은 이미 존재하는 email 입니다."); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateException.java b/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateException.java deleted file mode 100644 index bb96a25dc..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.sprint.mission.discodeit.exception.duplicate; - -public class DuplicateException extends RuntimeException { - public DuplicateException(String message) { - super(message); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateNameException.java b/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateNameException.java deleted file mode 100644 index 3ca03c406..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateNameException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.sprint.mission.discodeit.exception.duplicate; - -public class DuplicateNameException extends DuplicateException { - public DuplicateNameException(String userName) { - super(userName + "은 이미 존재합니다."); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/handler/GlobalExceptionHandler.java index 0061ca904..f11e226f4 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/handler/GlobalExceptionHandler.java @@ -1,48 +1,115 @@ package com.sprint.mission.discodeit.exception.handler; -import com.sprint.mission.discodeit.exception.LoginFailedException; -import com.sprint.mission.discodeit.exception.PrivateChannelModificationException; -import com.sprint.mission.discodeit.exception.alreadyexist.AlreadyExistsException; -import com.sprint.mission.discodeit.exception.duplicate.DuplicateException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundException; +import com.sprint.mission.discodeit.dto.response.ErrorResponse; +import com.sprint.mission.discodeit.entity.ErrorCode; +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.binarycontent.NotFoundBinaryContentException; +import com.sprint.mission.discodeit.exception.channel.NotFoundChannelException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.exception.message.NotFoundMessageException; +import com.sprint.mission.discodeit.exception.readstatus.NotFoundReadStatusException; +import com.sprint.mission.discodeit.exception.readstatus.ReadStatusAlreadyExistsException; +import com.sprint.mission.discodeit.exception.user.DuplicateEmailException; +import com.sprint.mission.discodeit.exception.user.DuplicateNameException; +import com.sprint.mission.discodeit.exception.user.LoginFailedException; +import com.sprint.mission.discodeit.exception.user.NotFoundUserException; +import com.sprint.mission.discodeit.exception.userstatus.NotFoundUserStatusException; +import com.sprint.mission.discodeit.exception.userstatus.UserStatusAlreadyExistsException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; -@ControllerAdvice +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(LoginFailedException.class) - public ResponseEntity handleLoginFailedException(Exception e) { - return ResponseEntity - .status(HttpStatus.UNAUTHORIZED) // 401 - .body(e.getMessage()); + @ExceptionHandler({NotFoundUserException.class, NotFoundChannelException.class, + NotFoundMessageException.class, NotFoundBinaryContentException.class, + NotFoundUserStatusException.class, NotFoundReadStatusException.class}) + public ResponseEntity handleNotFoundException(DiscodeitException e) { + ErrorResponse errorResponse = new ErrorResponse( + Instant.now(), + e.getErrorCode().toString(), + e.getMessage(), + e.getDetails(), + e.getClass().getTypeName(), + HttpStatus.NOT_FOUND.value() + ); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); } - @ExceptionHandler(NotFoundException.class) - public ResponseEntity handleNotFoundException(Exception e) { - return ResponseEntity - .status(HttpStatus.NOT_FOUND) - .body(e.getMessage()); + @ExceptionHandler({DuplicateEmailException.class, DuplicateNameException.class, + UserStatusAlreadyExistsException.class, ReadStatusAlreadyExistsException.class, + PrivateChannelUpdateException.class}) + public ResponseEntity handleBadRequestException(DiscodeitException e) { + ErrorResponse errorResponse = new ErrorResponse( + Instant.now(), + e.getErrorCode().toString(), + e.getMessage(), + e.getDetails(), + e.getClass().getTypeName(), + HttpStatus.BAD_REQUEST.value() + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } - - @ExceptionHandler({PrivateChannelModificationException.class, DuplicateException.class, - AlreadyExistsException.class}) - public ResponseEntity handleBadRequestException(Exception e) { - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(e.getMessage()); + + @ExceptionHandler(LoginFailedException.class) + public ResponseEntity handleLoginFailedException(DiscodeitException e) { + ErrorResponse errorResponse = new ErrorResponse( + Instant.now(), + e.getErrorCode().toString(), + e.getMessage(), + e.getDetails(), + e.getClass().getTypeName(), + HttpStatus.UNAUTHORIZED.value() + ); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse); + } + + // @Valid 검증 실패 시 예외 처리 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException e) { + List fieldErrors = e.getBindingResult().getFieldErrors(); + + Map details = new HashMap<>(); + for (FieldError fieldError : fieldErrors) { + details.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + + ErrorResponse errorResponse = new ErrorResponse( + Instant.now(), + "VALIDATION_FAILED", + "유효성 검사 실패", + details, + e.getClass().getTypeName(), + HttpStatus.BAD_REQUEST.value() + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /* 정의되지 않은 예외 처리 */ @ExceptionHandler(RuntimeException.class) - public ResponseEntity handleUnhandledException(Exception e) { - - e.printStackTrace(); + public ResponseEntity handleUnhandledException(Exception e) { + ErrorResponse errorResponse = new ErrorResponse( + Instant.now(), + ErrorCode.INTERNAL_SERVER_ERROR.toString(), + e.getMessage(), + null, + e.getClass().getTypeName(), + HttpStatus.INTERNAL_SERVER_ERROR.value() + ); - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("서버 내부 오류가 발생했습니다."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); } } diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java new file mode 100644 index 000000000..0301ece60 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.exception.message; + +import com.sprint.mission.discodeit.entity.ErrorCode; +import com.sprint.mission.discodeit.exception.DiscodeitException; + +import java.util.Map; + +public class MessageException extends DiscodeitException { + + public MessageException(String message, ErrorCode errorCode, Map details) { + super(message, errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/NotFoundMessageException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/NotFoundMessageException.java new file mode 100644 index 000000000..72d51c188 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/NotFoundMessageException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.message; + +import com.sprint.mission.discodeit.entity.ErrorCode; + +import java.util.Map; +import java.util.UUID; + +public class NotFoundMessageException extends MessageException { + + public NotFoundMessageException(UUID messageId) { + super( + ErrorCode.MESSAGE_NOT_FOUND.getMessage() + " messageId: + " + messageId, + ErrorCode.MESSAGE_NOT_FOUND, + Map.of("messageId", messageId) + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundBinaryContentException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundBinaryContentException.java deleted file mode 100644 index bb7517e85..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundBinaryContentException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.sprint.mission.discodeit.exception.notfound; - -public class NotFoundBinaryContentException extends NotFoundException { - public NotFoundBinaryContentException() { - super("존재하지 않는 파일입니다."); - } - - public NotFoundBinaryContentException(String message) { - super(message); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundChannelException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundChannelException.java deleted file mode 100644 index 2e9defb1b..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundChannelException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.sprint.mission.discodeit.exception.notfound; - -public class NotFoundChannelException extends NotFoundException { - public NotFoundChannelException() { - super("존재하지 않는 채널입니다."); - } - - public NotFoundChannelException(String message) { - super(message); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundException.java deleted file mode 100644 index 367546797..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.sprint.mission.discodeit.exception.notfound; - -public class NotFoundException extends RuntimeException { - public NotFoundException(String message) { - super(message); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundMessageException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundMessageException.java deleted file mode 100644 index cd7c741ff..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundMessageException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.sprint.mission.discodeit.exception.notfound; - -public class NotFoundMessageException extends NotFoundException { - public NotFoundMessageException() { - super("존재하지 않는 메시지입니다."); - } - - public NotFoundMessageException(String message) { - super(message); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundReadStatusException.java deleted file mode 100644 index 91f679fa9..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundReadStatusException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.sprint.mission.discodeit.exception.notfound; - -public class NotFoundReadStatusException extends NotFoundException { - public NotFoundReadStatusException() { - super("존재하지 않는 ReadStatus 입니다."); - } - - public NotFoundReadStatusException(String message) { - super(message); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundUserException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundUserException.java deleted file mode 100644 index 2fb24292d..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundUserException.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.sprint.mission.discodeit.exception.notfound; - -public class NotFoundUserException extends NotFoundException { - - public NotFoundUserException() { - super("존재하지 않는 사용자입니다."); - } - - public NotFoundUserException(String message) { - super(message); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundUserStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundUserStatusException.java deleted file mode 100644 index 2856847d3..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundUserStatusException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.sprint.mission.discodeit.exception.notfound; - -public class NotFoundUserStatusException extends NotFoundException { - public NotFoundUserStatusException() { - super("해당 UserStatus가 존재하지 않습니다."); - } - - public NotFoundUserStatusException(String message) { - super(message); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/NotFoundReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/NotFoundReadStatusException.java new file mode 100644 index 000000000..70521d648 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/NotFoundReadStatusException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.entity.ErrorCode; + +import java.util.Map; +import java.util.UUID; + +public class NotFoundReadStatusException extends ReadStatusException { + + public NotFoundReadStatusException(UUID id) { + super( + ErrorCode.READ_STATUS_NOT_FOUND.getMessage() + " id: " + id, + ErrorCode.READ_STATUS_NOT_FOUND, + Map.of("readStatusId", id) + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusAlreadyExistsException.java new file mode 100644 index 000000000..4b2d74479 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusAlreadyExistsException.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.entity.ErrorCode; + +import java.util.Map; +import java.util.UUID; + +public class ReadStatusAlreadyExistsException extends ReadStatusException { + + public ReadStatusAlreadyExistsException(UUID userId, UUID channelId) { + super( + ErrorCode.READ_STATUS_ALREADY_EXIST.getMessage() + " userId: " + userId + " channelId: " + + channelId, + ErrorCode.READ_STATUS_ALREADY_EXIST, + Map.of("channelId", channelId, + "userId", userId) + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java new file mode 100644 index 000000000..bdde321f5 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.entity.ErrorCode; +import com.sprint.mission.discodeit.exception.DiscodeitException; + +import java.util.Map; + +public class ReadStatusException extends DiscodeitException { + + public ReadStatusException(String message, ErrorCode errorCode, Map details) { + super(message, errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/DuplicateEmailException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/DuplicateEmailException.java new file mode 100644 index 000000000..bf43cf4fe --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/DuplicateEmailException.java @@ -0,0 +1,16 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.entity.ErrorCode; + +import java.util.Map; + +public class DuplicateEmailException extends UserException { + + public DuplicateEmailException(String email) { + super( + ErrorCode.DUPLICATE_USER_EMAIL.getMessage() + " email: " + email, + ErrorCode.DUPLICATE_USER_EMAIL, + Map.of("email", email) + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/DuplicateNameException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/DuplicateNameException.java new file mode 100644 index 000000000..e8036221d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/DuplicateNameException.java @@ -0,0 +1,16 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.entity.ErrorCode; + +import java.util.Map; + +public class DuplicateNameException extends UserException { + + public DuplicateNameException(String username) { + super( + ErrorCode.DUPLICATE_USER_NAME.getMessage() + " name: " + username, + ErrorCode.DUPLICATE_USER_NAME, + Map.of("username", username) + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/LoginFailedException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/LoginFailedException.java new file mode 100644 index 000000000..11632df20 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/LoginFailedException.java @@ -0,0 +1,16 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.entity.ErrorCode; + +import java.util.Map; + +public class LoginFailedException extends UserException { + + public LoginFailedException(String username) { + super( + ErrorCode.LOGIN_FAILED.getMessage() + " username: " + username, + ErrorCode.LOGIN_FAILED, + Map.of("username", username) + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/NotFoundUserException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/NotFoundUserException.java new file mode 100644 index 000000000..50f94e22b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/NotFoundUserException.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.entity.ErrorCode; + +import java.util.Map; +import java.util.UUID; + +public class NotFoundUserException extends UserException { + + public NotFoundUserException(UUID userId) { + super( + ErrorCode.USER_NOT_FOUND.getMessage() + " userId: " + userId, + ErrorCode.USER_NOT_FOUND, + Map.of("userId", userId) + ); + } + + public NotFoundUserException(String username) { + super( + ErrorCode.USER_NOT_FOUND.getMessage() + " username: " + username, + ErrorCode.USER_NOT_FOUND, + Map.of("username", username) + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java new file mode 100644 index 000000000..4edc0f745 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.entity.ErrorCode; +import com.sprint.mission.discodeit.exception.DiscodeitException; + +import java.util.Map; + +public class UserException extends DiscodeitException { + + public UserException(String message, ErrorCode errorCode, Map details) { + super(message, errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/NotFoundUserStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/NotFoundUserStatusException.java new file mode 100644 index 000000000..a936fdf6f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/NotFoundUserStatusException.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.exception.userstatus; + +import com.sprint.mission.discodeit.entity.ErrorCode; + +import java.util.Map; +import java.util.UUID; + +public class NotFoundUserStatusException extends UserStatusException { + + public NotFoundUserStatusException() { + super( + ErrorCode.USER_STATUS_NOT_FOUND.getMessage(), + ErrorCode.USER_STATUS_NOT_FOUND, + null + ); + } + + public NotFoundUserStatusException(UUID userId) { + super( + ErrorCode.USER_STATUS_NOT_FOUND.getMessage() + " 사용자 id: " + userId, + ErrorCode.USER_STATUS_NOT_FOUND, + Map.of("userId", userId) + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusAlreadyExistsException.java new file mode 100644 index 000000000..24b8890c4 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusAlreadyExistsException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.userstatus; + +import com.sprint.mission.discodeit.entity.ErrorCode; + +import java.util.Map; +import java.util.UUID; + +public class UserStatusAlreadyExistsException extends UserStatusException { + + public UserStatusAlreadyExistsException(UUID userId) { + super( + ErrorCode.USER_STATUS_ALREADY_EXIST.getMessage() + "사용자 id: " + userId, + ErrorCode.USER_STATUS_ALREADY_EXIST, + Map.of("userId", userId) + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java new file mode 100644 index 000000000..78826b8ec --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.exception.userstatus; + +import com.sprint.mission.discodeit.entity.ErrorCode; +import com.sprint.mission.discodeit.exception.DiscodeitException; + +import java.util.Map; + +public class UserStatusException extends DiscodeitException { + + public UserStatusException(String message, ErrorCode errorCode, Map details) { + super(message, errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java index eba928886..c4925b061 100644 --- a/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java +++ b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java @@ -54,6 +54,6 @@ private Instant getLastMessageAt(Channel channel) { .stream() .map(Message::getCreatedAt) .findFirst() - .orElse(Instant.MIN); + .orElse(null); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java index 302559fbb..535560817 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java @@ -4,9 +4,9 @@ import com.sprint.mission.discodeit.dto.user.UserResponseDto; import com.sprint.mission.discodeit.entity.User; import com.sprint.mission.discodeit.entity.UserStatus; -import com.sprint.mission.discodeit.exception.LoginFailedException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundUserException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundUserStatusException; +import com.sprint.mission.discodeit.exception.user.LoginFailedException; +import com.sprint.mission.discodeit.exception.user.NotFoundUserException; +import com.sprint.mission.discodeit.exception.userstatus.NotFoundUserStatusException; import com.sprint.mission.discodeit.mapper.UserMapper; import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.repository.UserStatusRepository; @@ -34,10 +34,10 @@ public UserResponseDto login(LoginDto loginDTO) { String password = loginDTO.password(); User user = userRepository.findByUsername(username) - .orElseThrow(() -> new NotFoundUserException("username이 " + username + "인 사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new NotFoundUserException(username)); if (!user.getPassword().equals(password)) { - throw new LoginFailedException(); + throw new LoginFailedException(user.getUsername()); } // User 로그인 시 User 온라인 상태 정보 변경 @@ -51,6 +51,6 @@ public UserResponseDto login(LoginDto loginDTO) { private UserStatus findUserStatus(UUID userId) { return userStatusRepository.findByUserId(userId) - .orElseThrow(NotFoundUserStatusException::new); + .orElseThrow(() -> new NotFoundUserStatusException(userId)); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java index f785e2c1f..32e22fc1d 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java @@ -2,7 +2,7 @@ import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto; import com.sprint.mission.discodeit.entity.BinaryContent; -import com.sprint.mission.discodeit.exception.notfound.NotFoundBinaryContentException; +import com.sprint.mission.discodeit.exception.binarycontent.NotFoundBinaryContentException; import com.sprint.mission.discodeit.mapper.struct.BinaryContentStructMapper; import com.sprint.mission.discodeit.repository.BinaryContentRepository; import com.sprint.mission.discodeit.service.BinaryContentService; @@ -24,7 +24,7 @@ public class BasicBinaryContentService implements BinaryContentService { @Override public BinaryContentResponseDto findById(UUID id) { BinaryContent foundBinaryContent = binaryContentRepository.findById(id) - .orElseThrow(NotFoundBinaryContentException::new); + .orElseThrow(() -> new NotFoundBinaryContentException(id)); return binaryContentMapper.toDto(foundBinaryContent); } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java index 4e6e68289..7e20f8d2e 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java @@ -8,22 +8,23 @@ import com.sprint.mission.discodeit.entity.ChannelType; import com.sprint.mission.discodeit.entity.ReadStatus; import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.exception.PrivateChannelModificationException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundChannelException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundUserException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.exception.channel.NotFoundChannelException; +import com.sprint.mission.discodeit.exception.user.NotFoundUserException; import com.sprint.mission.discodeit.mapper.ChannelMapper; import com.sprint.mission.discodeit.repository.ChannelRepository; import com.sprint.mission.discodeit.repository.ReadStatusRepository; import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.service.ChannelService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; import java.util.List; import java.util.UUID; +@Slf4j @Service("basicChannelService") @RequiredArgsConstructor @Transactional(readOnly = true) @@ -41,6 +42,9 @@ public ChannelResponseDto createPublicChannel(PublicChannelDto publicChannelDto) String name = publicChannelDto.name(); String description = publicChannelDto.description(); + log.info("[BasicChannelService] 공개 채널 생성 요청 - name: {}, description: {}", + name, description); + Channel channel = Channel.builder() .name(name) .description(description) @@ -49,12 +53,17 @@ public ChannelResponseDto createPublicChannel(PublicChannelDto publicChannelDto) Channel savedChannel = channelRepository.save(channel); + log.info("[BasicChannelService] 공개 채널 생성 성공 - id: {}, name: {}, description: {}", + savedChannel.getId(), savedChannel.getName(), savedChannel.getDescription()); + return channelMapper.toDto(savedChannel); } @Override @Transactional public ChannelResponseDto createPrivateChannel(PrivateChannelDto privateChannelDto) { + log.info("[BasicChannelService] 개인 채널 생성 요청 participants: {}", privateChannelDto.participantIds().size()); + Channel channel = new Channel(); Channel createdChannel = channelRepository.save(channel); @@ -75,6 +84,8 @@ public ChannelResponseDto createPrivateChannel(PrivateChannelDto privateChannelD readStatusRepository.saveAll(readStatuses); + log.info("[BasicChannelService] 비공개 채널 생성 성공 id: {}", createdChannel.getId()); + return channelMapper.toDto(createdChannel); } @@ -104,36 +115,51 @@ public List findAllByUserId(UUID userId) { public ChannelResponseDto update(UUID channelId, PublicChannelUpdateDto publicChannelUpdateDto) { Channel channel = findChannel(channelId); + String newName = publicChannelUpdateDto.newName(); + String newDescription = publicChannelUpdateDto.newDescription(); + + log.info("[BasicChannelService] 공개 채널 수정 요청: id: {}, newName: {}, newDescription: {}", + channelId, newName, newDescription); + // PRIVATE 채널은 수정 불가 if (channel.getType().equals(ChannelType.PRIVATE)) { - throw new PrivateChannelModificationException(); + throw new PrivateChannelUpdateException(channelId); } // 변경 사항 적용 - channel.updateName(publicChannelUpdateDto.newName()); - channel.updateDescription(publicChannelUpdateDto.newDescription()); + channel.updateName(newName); + channel.updateDescription(newDescription); - channelRepository.save(channel); + Channel updatedChannel = channelRepository.save(channel); - return channelMapper.toDto(channel); + log.info("[BasicChannelService] 공개 채널 수정 성공: id: {}, newName: {}, newDescription: {}", + updatedChannel.getId(), updatedChannel.getName(), updatedChannel.getDescription()); + + return channelMapper.toDto(updatedChannel); } @Override @Transactional public void deleteById(UUID channelId) { + log.info("[BasicChannelService] 채널 삭제 요청: id: {}", channelId); + Channel channel = findChannel(channelId); + log.info("[BasicChannelService] 삭제할 채널 조회 완료: id: {}", channel.getId()); + channelRepository.deleteById(channelId); + + log.info("[BasicUserService] 채널 삭제 완료 - channelId: {}", channelId); } private Channel findChannel(UUID id) { return channelRepository.findById(id) - .orElseThrow(NotFoundChannelException::new); + .orElseThrow(() -> new NotFoundChannelException(id)); } private User findUser(UUID id) { return userRepository.findById(id) - .orElseThrow(NotFoundUserException::new); + .orElseThrow(() -> new NotFoundUserException(id)); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java index 49e918d1b..d1b619f1c 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java @@ -8,9 +8,9 @@ import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.Message; import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.exception.notfound.NotFoundChannelException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundMessageException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundUserException; +import com.sprint.mission.discodeit.exception.channel.NotFoundChannelException; +import com.sprint.mission.discodeit.exception.message.NotFoundMessageException; +import com.sprint.mission.discodeit.exception.user.NotFoundUserException; import com.sprint.mission.discodeit.mapper.MessageMapper; import com.sprint.mission.discodeit.mapper.struct.BinaryContentStructMapper; import com.sprint.mission.discodeit.repository.BinaryContentRepository; @@ -20,6 +20,7 @@ import com.sprint.mission.discodeit.service.MessageService; import com.sprint.mission.discodeit.storage.BinaryContentStorage; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -30,6 +31,7 @@ import java.util.List; import java.util.UUID; +@Slf4j @Service("basicMessageService") @RequiredArgsConstructor @Transactional(readOnly = true) @@ -63,6 +65,9 @@ public MessageResponseDto create(MessageRequestDto messageRequestDto, String content = messageRequestDto.content(); + log.info("[BasicMessageService] 메시지 생성 요청- authorId: {}, channelId: {}, content: {}", + author.getId(), channel.getId(), messageRequestDto.content()); + Message message = Message.builder() .content(content) .author(author) @@ -72,7 +77,11 @@ public MessageResponseDto create(MessageRequestDto messageRequestDto, message.updateAttachments(binaryContents); - messageRepository.save(message); + Message savedMessage = messageRepository.save(message); + + log.info("[BasicMessageService] 메시지 생성 성공- id: {}, authorId: {}, channelId: {}, content: {}", + savedMessage.getId(), savedMessage.getAuthor().getId(), savedMessage.getChannel().getId(), + savedMessage.getContent()); return messageMapper.toDto(message); } @@ -109,7 +118,7 @@ public PageResponse findAllByChannelId(UUID channelId, Insta Instant nextCursor = hasNext ? contentMessages.get(contentMessages.size() - 1).getCreatedAt() : null; - List content = messages.stream() + List content = contentMessages.stream() .map(messageMapper::toDto) .toList(); @@ -119,25 +128,35 @@ public PageResponse findAllByChannelId(UUID channelId, Insta @Override @Transactional public MessageResponseDto updateContent(UUID messageId, String content) { + log.info("[BasicMessageService] 메시지 내용 수정 요청- id: {} content: {}", messageId, content); + Message message = findMessage(messageId); message.updateContent(content); - messageRepository.save(message); + Message updatedMessage = messageRepository.save(message); - return messageMapper.toDto(message); + log.info("[BasicMessageService] 메시지 내용 수정 성공- id: {}, newContent: {}", updatedMessage.getId(), + updatedMessage.getContent()); + + return messageMapper.toDto(updatedMessage); } @Override @Transactional public void deleteById(UUID messageId) { + log.info("[BasicMessageService] 메시지 삭제 요청: id: {}", messageId); + Message message = findMessage(messageId); + log.info("[BasicMessageService] 삭제할 메시지 조회 완료: id: {}", message.getId()); for (BinaryContent binaryContent : message.getAttachments()) { binaryContentRepository.deleteById(binaryContent.getId()); } messageRepository.deleteById(messageId); + + log.info("[BasicMessageService] 메시지 삭제 성공: id: {}", messageId); } private List convertBinaryContentDtos(List binaryContentDtos) { @@ -148,16 +167,16 @@ private List convertBinaryContentDtos(List bina private Message findMessage(UUID id) { return messageRepository.findById(id) - .orElseThrow(NotFoundMessageException::new); + .orElseThrow(() -> new NotFoundMessageException(id)); } private Channel findChannel(UUID id) { return channelRepository.findById(id) - .orElseThrow(NotFoundChannelException::new); + .orElseThrow(() -> new NotFoundChannelException(id)); } private User findUser(UUID id) { return userRepository.findById(id) - .orElseThrow(NotFoundUserException::new); + .orElseThrow(() -> new NotFoundUserException(id)); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java index baa5582a3..28c5f7741 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java @@ -6,10 +6,10 @@ import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.ReadStatus; import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.exception.alreadyexist.ReadStatusAlreadyExistsException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundChannelException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundReadStatusException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundUserException; +import com.sprint.mission.discodeit.exception.readstatus.ReadStatusAlreadyExistsException; +import com.sprint.mission.discodeit.exception.channel.NotFoundChannelException; +import com.sprint.mission.discodeit.exception.readstatus.NotFoundReadStatusException; +import com.sprint.mission.discodeit.exception.user.NotFoundUserException; import com.sprint.mission.discodeit.mapper.struct.ReadStatusStructMapper; import com.sprint.mission.discodeit.repository.ChannelRepository; import com.sprint.mission.discodeit.repository.ReadStatusRepository; @@ -40,15 +40,15 @@ public ReadStatusResponseDto create(ReadStatusRequestDto readStatusRequestDTO) { UUID channelId = readStatusRequestDTO.channelId(); if (!userRepository.existsById(userId)) { - throw new NotFoundUserException(); + throw new NotFoundUserException(userId); } if (!channelRepository.existsById(channelId)) { - throw new NotFoundChannelException(); + throw new NotFoundChannelException(channelId); } if (readStatusRepository.existsByUserIdAndChannelId(userId, channelId)) { - throw new ReadStatusAlreadyExistsException(); + throw new ReadStatusAlreadyExistsException(userId, channelId); } User user = findUser(userId); @@ -103,16 +103,16 @@ public void deleteById(UUID id) { private ReadStatus findReadStatus(UUID id) { return readStatusRepository.findById(id) - .orElseThrow(NotFoundReadStatusException::new); + .orElseThrow(() -> new NotFoundReadStatusException(id)); } private User findUser(UUID id) { return userRepository.findById(id) - .orElseThrow(NotFoundUserException::new); + .orElseThrow(() -> new NotFoundUserException(id)); } private Channel findChannel(UUID id) { return channelRepository.findById(id) - .orElseThrow(NotFoundChannelException::new); + .orElseThrow(() -> new NotFoundChannelException(id)); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java index 153b1f882..f9f92bb65 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java @@ -7,10 +7,10 @@ import com.sprint.mission.discodeit.entity.BinaryContent; import com.sprint.mission.discodeit.entity.User; import com.sprint.mission.discodeit.entity.UserStatus; -import com.sprint.mission.discodeit.exception.duplicate.DuplicateEmailException; -import com.sprint.mission.discodeit.exception.duplicate.DuplicateNameException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundUserException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundUserStatusException; +import com.sprint.mission.discodeit.exception.user.DuplicateEmailException; +import com.sprint.mission.discodeit.exception.user.DuplicateNameException; +import com.sprint.mission.discodeit.exception.user.NotFoundUserException; +import com.sprint.mission.discodeit.exception.userstatus.NotFoundUserStatusException; import com.sprint.mission.discodeit.mapper.UserMapper; import com.sprint.mission.discodeit.mapper.struct.BinaryContentStructMapper; import com.sprint.mission.discodeit.repository.BinaryContentRepository; @@ -19,6 +19,7 @@ import com.sprint.mission.discodeit.service.UserService; import com.sprint.mission.discodeit.storage.BinaryContentStorage; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,6 +28,7 @@ import java.util.Optional; import java.util.UUID; +@Slf4j @Service("basicUserService") @RequiredArgsConstructor @Transactional(readOnly = true) @@ -45,6 +47,8 @@ public UserResponseDto create(UserRequestDto userRequestDto, BinaryContentDto bi String username = userRequestDto.username(); String email = userRequestDto.email(); + log.info("[BasicUserService] 사용자 등록 요청 - username: {}, email: {}", username, email); + if (userRepository.existsByUsername(username)) { throw new DuplicateNameException(username); } @@ -81,10 +85,13 @@ public UserResponseDto create(UserRequestDto userRequestDto, BinaryContentDto bi user.updateStatus(userStatus); - userRepository.save(user); + User savedUser = userRepository.save(user); userStatusRepository.save(userStatus); - return userMapper.toDto(user); + log.info("[BasicUserService] 사용자 등록 성공 - id: {}, username: {}, email: {}", + savedUser.getId(), username, email); + + return userMapper.toDto(savedUser); } @Override @@ -121,6 +128,9 @@ public UserResponseDto update(UUID id, UserUpdateDto userUpdateDto, String newUsername = userUpdateDto.newUsername(); String newEmail = userUpdateDto.newEmail(); + log.info("[BasicUserService] 사용자 수정 요청: id: {}, newUsername: {}, newEmail: {}", + id, newUsername, newEmail); + if (newUsername != null) { userRepository.findByUsername(newUsername) .filter(u -> !u.getId().equals(user.getId())) @@ -162,31 +172,41 @@ public UserResponseDto update(UUID id, UserUpdateDto userUpdateDto, Optional.ofNullable(userUpdateDto.newPassword()).ifPresent(user::updatePassword); - userRepository.save(user); + User updatedUser = userRepository.save(user); - return userMapper.toDto(user); + log.info("[BasicUserService] 사용자 수정 성공! id: {}, username: {}, email: {}", + updatedUser.getId(), updatedUser.getUsername(), updatedUser.getEmail()); + + return userMapper.toDto(updatedUser); } @Override @Transactional public void deleteById(UUID id) { + log.info("[BasicUserService] 사용자 삭제 요청: id: {}", id); + User user = findUser(id); + log.debug("[BasicUserService] 사용자 조회 완료- id: {}, username: {}", user.getId(), user.getUsername()); userRepository.deleteById(id); + log.debug("[BasicUserService] userRepository 삭제 완료 - userId: {}", id); + userStatusRepository.deleteByUserId(id); if (user.getProfile() != null) { binaryContentRepository.deleteById(user.getProfile().getId()); } + + log.info("[BasicUserService] 사용자 삭제 완료 - userId: {}", id); } private User findUser(UUID id) { return userRepository.findById(id) - .orElseThrow(NotFoundUserException::new); + .orElseThrow(() -> new NotFoundUserException(id)); } private UserStatus findUserStatus(UUID id) { return userStatusRepository.findByUserId(id) - .orElseThrow(NotFoundUserStatusException::new); + .orElseThrow(() -> new NotFoundUserStatusException(id)); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java index ea064b94d..cd44c225d 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java @@ -5,9 +5,9 @@ import com.sprint.mission.discodeit.dto.userstatus.UserStatusUpdateDto; import com.sprint.mission.discodeit.entity.User; import com.sprint.mission.discodeit.entity.UserStatus; -import com.sprint.mission.discodeit.exception.alreadyexist.UserStatusAlreadyExistsException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundUserException; -import com.sprint.mission.discodeit.exception.notfound.NotFoundUserStatusException; +import com.sprint.mission.discodeit.exception.userstatus.UserStatusAlreadyExistsException; +import com.sprint.mission.discodeit.exception.user.NotFoundUserException; +import com.sprint.mission.discodeit.exception.userstatus.NotFoundUserStatusException; import com.sprint.mission.discodeit.mapper.struct.UserStatusStructMapper; import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.repository.UserStatusRepository; @@ -34,11 +34,11 @@ public class BasicUserStatusService implements UserStatusService { public UserStatusResponseDto create(UserStatusRequestDto userStatusRequestDTO) { UUID userId = userStatusRequestDTO.userId(); if (!userRepository.existsById(userId)) { - throw new NotFoundUserException(); + throw new NotFoundUserException(userId); } if (userStatusRepository.existsByUserId(userId)) { - throw new UserStatusAlreadyExistsException(); + throw new UserStatusAlreadyExistsException(userId); } User user = findUser(userStatusRequestDTO.userId()); @@ -84,7 +84,7 @@ public UserStatusResponseDto update(UUID id, UserStatusUpdateDto userStatusUpdat public UserStatusResponseDto updateByUserId(UUID userId, UserStatusUpdateDto userStatusUpdateDTO) { UserStatus userStatus = userStatusRepository.findByUserId(userId) - .orElseThrow(NotFoundUserStatusException::new); + .orElseThrow(() -> new NotFoundUserStatusException(userId)); userStatus.updatelastActiveAt(userStatusUpdateDTO.newLastActiveAt()); userStatusRepository.save(userStatus); @@ -105,6 +105,6 @@ private UserStatus findUserStatus(UUID id) { private User findUser(UUID id) { return userRepository.findById(id) - .orElseThrow(NotFoundUserException::new); + .orElseThrow(() -> new NotFoundUserException(id)); } } diff --git a/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java index 95d28f26b..020223d44 100644 --- a/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java +++ b/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java @@ -2,6 +2,7 @@ import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto; import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -20,6 +21,7 @@ import java.nio.file.Paths; import java.util.UUID; +@Slf4j @Component @ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "local") public class LocalBinaryContentStorage implements BinaryContentStorage { @@ -55,11 +57,17 @@ private Path resolvePath(UUID id) { public UUID put(UUID id, byte[] bytes) { Path path = resolvePath(id); + log.info("파일 업로드 요청: id = {}, size: {} bytes", id, bytes.length); + log.debug("저장 경로: {}", path.toAbsolutePath()); + try { Files.createDirectories(path.getParent()); Files.write(path, bytes); + log.info("파일 업로드 성공: id={}", id); + return id; } catch (IOException e) { + log.error("파일 저장 실패: id={}", id, e); throw new UncheckedIOException("파일 저장 실패: " + id, e); } } @@ -79,6 +87,9 @@ public InputStream get(UUID id) { public ResponseEntity download(BinaryContentResponseDto dto) { Path path = resolvePath(dto.id()); + log.info("파일 다운로드 요청: id={}, fileName={}", dto.id(), dto.fileName()); + log.debug("다운로드 경로: {}", path); + try { // MIME 타입 String contentType = Files.probeContentType(path); @@ -87,6 +98,7 @@ public ResponseEntity download(BinaryContentResponseDto dto) { } Resource resource = new FileSystemResource(path); + log.info("파일 다운로드 성공: id={}, contentType={}", dto.id(), contentType); return ResponseEntity.ok() .contentType(MediaType.parseMediaType(contentType)) @@ -94,6 +106,7 @@ public ResponseEntity download(BinaryContentResponseDto dto) { .contentLength(dto.size()) .body(resource); } catch (IOException e) { + log.error("파일 다운로드 실패: id={}", dto.id(), e); throw new UncheckedIOException("다운로드 실패: " + dto.id(), e); } } diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java new file mode 100644 index 000000000..a4239a683 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java @@ -0,0 +1,90 @@ +package com.sprint.mission.discodeit.storage.s3; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.URL; +import java.time.Duration; +import java.util.Properties; + +public class AWSS3Test { + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + private final String bucketName; + + public AWSS3Test() throws IOException { + Properties properties = new Properties(); + properties.load(new FileInputStream(".env")); + + String accessKey = properties.getProperty("AWS_S3_ACCESS_KEY"); + String secretKey = properties.getProperty("AWS_S3_SECRET_KEY"); + String region = properties.getProperty("AWS_S3_REGION"); + this.bucketName = properties.getProperty("AWS_S3_BUCKET"); + + AwsBasicCredentials awsCreds = AwsBasicCredentials.create(accessKey, secretKey); + + this.s3Client = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCreds)) + .build(); + + this.s3Presigner = S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCreds)) + .build(); + } + + public void uploadTest(String key, String filePath) { + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build(); + + s3Client.putObject(putRequest, new File(filePath).toPath()); + System.out.println("Upload completed: " + key); + } + + public void downloadTest(String key, String downloadPath) { + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build(); + + s3Client.getObject(getRequest, new File(downloadPath).toPath()); + System.out.println("Download completed: " + downloadPath); + } + + public void presignedUrlTest(String key) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .getObjectRequest(getObjectRequest) + .build(); + + URL presignedUrl = s3Presigner.presignGetObject(presignRequest).url(); + System.out.println("presignedUrl.toString() = " + presignedUrl.toString()); + } + + public static void main(String[] args) throws IOException { + + AWSS3Test test = new AWSS3Test(); + + test.uploadTest("test.jpg", "/Users/handongwoo/sprint-mission/3-sprint-mission/src/test.jpg"); + test.downloadTest("test.jpg", "/Users/handongwoo/sprint-mission/3-sprint-mission/src/downloaded.jpg"); + test.presignedUrlTest("test.jpg"); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java new file mode 100644 index 000000000..6133cf881 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java @@ -0,0 +1,119 @@ +package com.sprint.mission.discodeit.storage.s3; + +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.UrlResource; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +import java.io.InputStream; +import java.time.Duration; +import java.util.UUID; + +@Component +@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "s3") +public class S3BinaryContentStorage implements BinaryContentStorage { + + private final String accessKey; + private final String secretKey; + private final String region; + private final String bucket; + + public S3BinaryContentStorage(@Value("${discodeit.storage.s3.access-key}") String accessKey, + @Value("${discodeit.storage.s3.secret-key}") String secretKey, + @Value("${discodeit.storage.s3.region}") String region, + @Value("${discodeit.storage.s3.bucket}") String bucket) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + this.bucket = bucket; + } + + @Override + public UUID put(UUID id, byte[] bytes) { + S3Client s3Client = getS3Client(); + String key = id.toString(); + + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + + s3Client.putObject(putRequest, RequestBody.fromBytes(bytes)); + + return id; + } + + @Override + public InputStream get(UUID id) { + S3Client s3Client = getS3Client(); + String key = id.toString(); + + return s3Client.getObject(GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build()); + } + + @Override + public ResponseEntity download(BinaryContentResponseDto binaryContentResponseDto) { + String key = binaryContentResponseDto.id().toString(); + String presignedUrl = generatePresignedUrl(key, binaryContentResponseDto.contentType()); + + try { + UrlResource url = new UrlResource(presignedUrl); + + return ResponseEntity + .status(302) + .location(url.getURL().toURI()) + .build(); + + } catch (Exception e) { + throw new RuntimeException("Failed to create redirect for presigned URL", e); + } + } + + S3Client getS3Client() { + AwsBasicCredentials awsCreds = AwsBasicCredentials.create(accessKey, secretKey); + + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCreds)) + .build(); + } + + String generatePresignedUrl(String key, String contentType) { + AwsBasicCredentials awsCreds = AwsBasicCredentials.create(accessKey, secretKey); + + S3Presigner s3Presigner = S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCreds)) + .build(); + + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .responseContentType(contentType) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .getObjectRequest(getRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest) + .url() + .toString(); + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 000000000..65d408f3d --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,23 @@ +spring: + h2: + console: + enabled: true + path: /h2-console + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + +server: + port: 8081 + +logging: + level: + com.sprint.mission.discodeit: DEBUG diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 000000000..91e0971a5 --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,14 @@ +spring: + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: org.postgresql.Driver + # JPA 설정 + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + +server: + port: 80 \ No newline at end of file diff --git a/src/main/resources/application-test.yaml b/src/main/resources/application-test.yaml new file mode 100644 index 000000000..021ad62f3 --- /dev/null +++ b/src/main/resources/application-test.yaml @@ -0,0 +1,19 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;MODE=PostgreSQL + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + default_schema: discodeit + +logging: + level: + root: INFO + org.springframework: DEBUG diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 0830ad905..2c56bc50e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,43 +1,69 @@ +spring: + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: validate + show-sql: true + properties: + hibernate: + format_sql: true + use_sql_comments: true + open-in-view: false + +management: + endpoints: + web: + exposure: + include: health, info, metrics, loggers + endpoint: + info: + enabled: true + health: + show-details: always + info: + env: + enabled: true + java: + enabled: true + os: + enabled: true + discodeit: storage: - type: local + type: ${STORAGE_TYPE:local} local: - root-path: ./binaries + root-path: ${STORAGE_LOCAL_ROOT_PATH:./binaries} + s3: + access-key: ${AWS_S3_ACCESS_KEY} + secret-key: ${AWS_S3_SECRET_KEY} + region: ${AWS_S3_REGION} + bucket: ${AWS_S3_BUCKET} + presigned-url-expiration: ${AWS_S3_PRESIGNED_URL_EXPIRATION:600} springdoc: api-docs: - # OpenAPI 명세서 경로(JSON) path: /v3/api-docs swagger-ui: - # OpenAPI 명세서 경로(HTML) path: /swagger-ui.html - # 요청 응답 시간 표시 display-request-duration: true - # HTTP 메서드 기준으로 정렬 operations-sorter: method -spring: - # DB 접속 정보 - datasource: - url: jdbc:postgresql://localhost:5432/discodeit - username: discodeit_user - password: discodeit1234 - driver-class-name: org.postgresql.Driver - # JPA 설정 - jpa: - hibernate: - ddl-auto: create - show-sql: true - properties: - hibernate: - format_sql: true - highlight_sql: true - use_sql_comments: true - dialect: org.hibernate.dialect.PostgreSQLDialect - open-in-view: false +info: + app: + name: Discodeit + version: 1.7.0 + java: + version: 17 + spring-boot: + version: 3.4.0 -# logging 설정 logging: level: + com.sprint.mission.discodeit: INFO org.hibernate.sql: debug - org.hibernate.orm.jdbc.bind: trace + org.hibernate.orm.jdbc.bind: trace \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..99dd18983 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + UTF-8 + + + + + + ${LOG_PATH}/${LOG_FILE_NAME}-application.log + + ${LOG_PATH}/${LOG_FILE_NAME}-application-%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 5GB + + + ${FILE_LOG_PATTERN} + UTF-8 + + + + + + ${LOG_PATH}/${LOG_FILE_NAME}-business.log + + ${LOG_PATH}/${LOG_FILE_NAME}-business-%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 5GB + + + ${FILE_LOG_PATTERN} + UTF-8 + + + + + + ${LOG_PATH}/${LOG_FILE_NAME}-error.log + + ERROR + ACCEPT + DENY + + + ${LOG_PATH}/${LOG_FILE_NAME}-error-%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 5GB + + + ${FILE_LOG_PATTERN} + UTF-8 + + + + + + ${LOG_PATH}/${LOG_FILE_NAME}-performance.log + + ${LOG_PATH}/${LOG_FILE_NAME}-performance-%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 5GB + + + ${FILE_LOG_PATTERN} + UTF-8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index f0610ebe5..785ff02d6 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,47 +1,24 @@ --- 1. distcodeit 스키마 생성 CREATE SCHEMA IF NOT EXISTS discodeit; --- 2. 권한 설정 -GRANT ALL PRIVILEGES ON SCHEMA discodeit TO discodeit_user; -GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA discodeit TO discodeit_user; --- 3. 권한 설정 확인 -SELECT - n.nspname AS schema_name, - pg_get_userbyid(n.nspowner) AS schema_owner -FROM - pg_namespace n -WHERE - n.nspname = 'discodeit'; --- 4. 검색 경로 설정 -ALTER ROLE discodeit_user SET search_path TO discodeit, public; --- 5. 검색 경로 확인 -SHOW search_path; --- 6. 테이블 삭제 -DROP TABLE IF EXISTS users CASCADE; -DROP TABLE IF EXISTS binary_contents CASCADE; -DROP TABLE IF EXISTS user_statuses CASCADE; -DROP TABLE IF EXISTS read_statuses CASCADE; -DROP TABLE IF EXISTS message_attachments CASCADE; -DROP TABLE IF EXISTS channels CASCADE; -DROP TABLE IF EXISTS messages CASCADE; --- 7. 테이블 생성 + +-- 테이블 생성 CREATE TABLE IF NOT EXISTS binary_contents ( - id UUID, - created_at TIMESTAMPTZ NOT NULL, - file_name VARCHAR(255) NOT NULL, - size BIGINT NOT NULL, - content_type VARCHAR(100) NOT NULL, + id UUID, + created_at TIMESTAMP with time zone NOT NULL, + file_name VARCHAR(255) NOT NULL, + size BIGINT NOT NULL, + content_type VARCHAR(100) NOT NULL, PRIMARY KEY (id) ); CREATE TABLE IF NOT EXISTS users ( - id UUID, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ, - username VARCHAR(50) NOT NULL UNIQUE, - email VARCHAR(100) NOT NULL UNIQUE, - password VARCHAR(60) NOT NULL, + id UUID, + created_at TIMESTAMP with time zone NOT NULL, + updated_at TIMESTAMP with time zone, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(60) NOT NULL, profile_id UUID, PRIMARY KEY (id), FOREIGN KEY (profile_id) @@ -51,11 +28,11 @@ CREATE TABLE IF NOT EXISTS users CREATE TABLE IF NOT EXISTS user_statuses ( - id UUID, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ, - user_id UUID NOT NULL UNIQUE, - last_active_at TIMESTAMPTZ NOT NULL, + id UUID, + created_at TIMESTAMP with time zone NOT NULL, + updated_at TIMESTAMP with time zone, + user_id UUID NOT NULL UNIQUE, + last_active_at TIMESTAMP with time zone NOT NULL, PRIMARY KEY (id), FOREIGN KEY (user_id) REFERENCES users (id) @@ -64,24 +41,24 @@ CREATE TABLE IF NOT EXISTS user_statuses CREATE TABLE IF NOT EXISTS channels ( - id UUID, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ, - name VARCHAR(100), + id UUID, + created_at TIMESTAMP with time zone NOT NULL, + updated_at TIMESTAMP with time zone, + name VARCHAR(100), description VARCHAR(500), - type VARCHAR(20) NOT NULL, + type VARCHAR(20) NOT NULL, PRIMARY KEY (id), CONSTRAINT channel_type_check CHECK (type IN ('PUBLIC', 'PRIVATE')) ); CREATE TABLE IF NOT EXISTS messages ( - id UUID, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ, - content TEXT, + id UUID, + created_at TIMESTAMP with time zone NOT NULL, + updated_at TIMESTAMP with time zone, + content TEXT, channel_id UUID, - author_id UUID, + author_id UUID, PRIMARY KEY (id), FOREIGN KEY (channel_id) REFERENCES channels (id) @@ -93,12 +70,12 @@ CREATE TABLE IF NOT EXISTS messages CREATE TABLE IF NOT EXISTS read_statuses ( - id UUID, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ, - user_id UUID, - channel_id UUID, - last_read_at TIMESTAMPTZ NOT NULL, + id UUID, + created_at TIMESTAMP with time zone NOT NULL, + updated_at TIMESTAMP with time zone, + user_id UUID, + channel_id UUID, + last_read_at TIMESTAMP with time zone NOT NULL, PRIMARY KEY (id), FOREIGN KEY (user_id) REFERENCES users (id) @@ -111,7 +88,7 @@ CREATE TABLE IF NOT EXISTS read_statuses CREATE TABLE IF NOT EXISTS message_attachments ( - message_id UUID, + message_id UUID, attachment_id UUID, PRIMARY KEY (message_id, attachment_id), FOREIGN KEY (message_id) diff --git a/src/test/java/com/sprint/mission/discodeit/ChannelServiceIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/ChannelServiceIntegrationTest.java new file mode 100644 index 000000000..cbfa6ea9d --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/ChannelServiceIntegrationTest.java @@ -0,0 +1,151 @@ +package com.sprint.mission.discodeit; + +import com.sprint.mission.discodeit.dto.channel.ChannelResponseDto; +import com.sprint.mission.discodeit.dto.channel.PrivateChannelDto; +import com.sprint.mission.discodeit.entity.*; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.ChannelService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("ChannelService 통합 테스트") +@Transactional +public class ChannelServiceIntegrationTest { + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ReadStatusRepository readStatusRepository; + @Autowired + private ChannelService channelService; + + private UUID userId1; + private UUID userId2; + private UUID userId3; + + @BeforeEach + void setUp() { + // given + User user1 = User.builder() + .username("test") + .email("test@test.com") + .password("pwd1234") + .build(); + + User user2 = User.builder() + .username("test2") + .email("test2@test.com") + .password("pwd12345") + .build(); + + User user3 = User.builder() + .username("test3") + .email("test3@test.com") + .password("pwd123456") + .build(); + + UserStatus userStatus1 = UserStatus.builder() + .user(user1) + .lastActiveAt(Instant.now()) + .build(); + + UserStatus userStatus2 = UserStatus.builder() + .user(user2) + .lastActiveAt(Instant.now()) + .build(); + + UserStatus userStatus3 = UserStatus.builder() + .user(user3) + .lastActiveAt(Instant.now()) + .build(); + + user1.updateStatus(userStatus1); + user2.updateStatus(userStatus2); + user3.updateStatus(userStatus3); + + User savedUser1 = userRepository.save(user1); + User savedUser2 = userRepository.save(user2); + User savedUser3 = userRepository.save(user3); + + userId1 = savedUser1.getId(); + userId2 = savedUser2.getId(); + userId3 = savedUser3.getId(); + } + + @Test + @DisplayName("Private 채널 등록 프로세스가 모든 계층에서 올바르게 동작해야 한다.") + void completeCreatePrivateChannelIntegration() { + + // given + PrivateChannelDto request = new PrivateChannelDto(List.of(userId1, userId2)); + + // when + ChannelResponseDto result = channelService.createPrivateChannel(request); + + // then + Optional foundChannel = channelRepository.findById(result.id()); + assertTrue(foundChannel.isPresent(), "채널이 저장되어 있어야 한다."); + assertNotNull(result); + assertEquals(ChannelType.PRIVATE, result.type()); + assertEquals(result.participants().get(0).id(), userId1); + assertNull(result.lastMessageAt(), "초기에는 메시지가 없어야 하므로 lastMessageAt은 null이어야 한다."); + List foundReadStatus1 = readStatusRepository.findAllByUserId(userId1); + assertEquals(1, foundReadStatus1.size(), "유저 1명의 ReadStatus가 정확히 하나 생성되어야 한다."); + } + + @Test + @DisplayName("사용자 ID로 채널 조회 프로세스가 모든 계층에서 올바르게 동작해야 한다.") + void completeFindByUserIdIntegration() { + + // given + Channel publicChannel = Channel.builder() + .name("public") + .description("test channel") + .type(ChannelType.PUBLIC) + .build(); + + /* + Private 채널 요청 + */ + PrivateChannelDto request1 = new PrivateChannelDto(List.of(userId1, userId2)); + PrivateChannelDto request2 = new PrivateChannelDto(List.of(userId2, userId3)); + + Channel savedChannel1 = channelRepository.save(publicChannel); + ChannelResponseDto privateChannel1 = channelService.createPrivateChannel(request1); + ChannelResponseDto privateChannel2 = channelService.createPrivateChannel(request2); + + UUID channelId1 = savedChannel1.getId(); + UUID channelId2 = privateChannel1.id(); + UUID channelId3 = privateChannel2.id(); + + // when + List channels = channelService.findAllByUserId(userId1); + + // then + assertEquals(2, channels.size(), "Public 채널과 참여한 Private 채널이 조회되어야 한다."); + List resultIds = channels.stream().map(ChannelResponseDto::id).toList(); + assertTrue(resultIds.contains(channelId1)); + assertTrue(resultIds.contains(channelId2)); + assertFalse(resultIds.contains(channelId3)); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/MessageServiceIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/MessageServiceIntegrationTest.java new file mode 100644 index 000000000..0d97529e4 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/MessageServiceIntegrationTest.java @@ -0,0 +1,224 @@ +package com.sprint.mission.discodeit; + +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentDto; +import com.sprint.mission.discodeit.dto.message.MessageRequestDto; +import com.sprint.mission.discodeit.dto.message.MessageResponseDto; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.entity.*; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.MessageService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = { + "discodeit.storage.type=local", + "discodeit.storage.local.root-path=./binaryTest" +}) +@DisplayName("UserService 통합 테스트") +@Transactional +public class MessageServiceIntegrationTest { + + @Autowired + private MessageService messageService; + + @Autowired + private MessageRepository messageRepository; + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private BinaryContentRepository binaryContentRepository; + + @Autowired + private BinaryContentStorage binaryContentStorage; + + private User savedAuthor; + private Channel savedChannel1; + private Channel savedChannel2; + + @BeforeEach + void setUp() { + User author = User.builder() + .username("test") + .email("test@test.com") + .password("pwd1234") + .build(); + + UserStatus userStatus = UserStatus.builder() + .user(author) + .lastActiveAt(Instant.now()) + .build(); + + author.updateStatus(userStatus); + + savedAuthor = userRepository.save(author); + + Channel channel1 = Channel.builder() + .name("public") + .description("test channel") + .type(ChannelType.PUBLIC) + .build(); + + savedChannel1 = channelRepository.save(channel1); + + Channel channel2 = Channel.builder() + .name("public2") + .description("test channel2") + .type(ChannelType.PUBLIC) + .build(); + + savedChannel2 = channelRepository.save(channel2); + } + + @Test + @DisplayName("메시지 등록 프로세스가 모든 계층에서 올바르게 동작해야 한다.") + void completeCreateMessageIntegration() throws IOException { + + // given + byte[] imageBytes = "test".getBytes(StandardCharsets.UTF_8); + BinaryContentDto attachment = new BinaryContentDto("attachment.png", 3L, + "image/png", imageBytes); + + MessageRequestDto request = new MessageRequestDto("Hello", savedChannel1.getId(), + savedAuthor.getId()); + + // when + MessageResponseDto result = messageService.create(request, List.of(attachment)); + + // then + assertNotNull(result); + assertEquals("Hello", result.content()); + assertEquals(savedChannel1.getId(), result.channelId()); + assertEquals(savedAuthor.getId(), result.author().id()); + UUID messageId = result.id(); + Optional foundMessage = messageRepository.findById(messageId); + assertTrue(foundMessage.isPresent(), "메시지가 저장되어 있어야 한다."); + List attachments = foundMessage.get().getAttachments(); + assertEquals(1, attachments.size()); + assertEquals("attachment.png", attachments.get(0).getFileName()); + byte[] data = binaryContentStorage.get(attachments.get(0).getId()).readAllBytes(); + assertArrayEquals(imageBytes, data); + } + + @Test + @DisplayName("채널 ID로 메시지 조회 프로세스가 모든 계층에서 올바르게 동작해야 한다.") + void completeFindByChannelIdIntegration() { + + // given + Message message1 = Message.builder() + .author(savedAuthor) + .channel(savedChannel1) + .content("Hello") + .attachments(List.of()) + .build(); + + Message message2 = Message.builder() + .author(savedAuthor) + .channel(savedChannel2) + .content("Hi") + .attachments(List.of()) + .build(); + + Message message3 = Message.builder() + .author(savedAuthor) + .channel(savedChannel2) + .content("Nice") + .attachments(List.of()) + .build(); + + messageRepository.save(message1); + messageRepository.save(message2); + messageRepository.save(message3); + Pageable pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + PageResponse result = messageService.findAllByChannelId(savedChannel2.getId(), + null, pageable); + + // then + List content = result.content(); + assertEquals(2, content.size()); + assertEquals("Nice", content.get(0).content()); + assertEquals("Hi", content.get(1).content()); + assertFalse(result.hasNext(), "채널에 있는 메시지를 모두 조회했기 때문에 다음 메시지는 없어야한다."); + assertNull(result.nextCursor()); + } + + @Test + @DisplayName("메시지 삭제 프로세스가 모든 계층에서 올바르게 동작해야 한다.") + void completeDeleteMessageIntegration() { + + // given + BinaryContent attachment = new BinaryContent("attachment.png", 3L, "image/png"); + + Message message = Message.builder() + .author(savedAuthor) + .channel(savedChannel1) + .content("Hello") + .attachments(List.of(attachment)) + .build(); + + BinaryContent savedAttachment = binaryContentRepository.save(attachment); + Message savedMessage = messageRepository.save(message); + + UUID messageId = savedMessage.getId(); + UUID attachmentId = savedAttachment.getId(); + + // when + messageService.deleteById(messageId); + + // then + Optional foundMessage = messageRepository.findById(messageId); + assertTrue(foundMessage.isEmpty()); + Optional foundAttachment = binaryContentRepository.findById(attachmentId); + assertTrue(foundAttachment.isEmpty(), "메시지의 첨부 파일도 삭제되어야 한다."); + } + + @AfterEach + void cleanUpStorage() throws IOException { + Path testRoot = Paths.get("./binaryTest"); + if (Files.exists(testRoot)) { + Files.walk(testRoot) + .sorted((a, b) -> b.compareTo(a)) // 파일 먼저, 그 다음 디렉토리 삭제 + .forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + // 무시 또는 로깅 + } + }); + } + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/UserServiceIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/UserServiceIntegrationTest.java new file mode 100644 index 000000000..920f0cb75 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/UserServiceIntegrationTest.java @@ -0,0 +1,142 @@ +package com.sprint.mission.discodeit; + +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentDto; +import com.sprint.mission.discodeit.dto.user.UserRequestDto; +import com.sprint.mission.discodeit.dto.user.UserResponseDto; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import com.sprint.mission.discodeit.service.UserService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = { + "discodeit.storage.type=local", + "discodeit.storage.local.root-path=./binaryTest" +}) +@DisplayName("UserService 통합 테스트") +@Transactional +public class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private BinaryContentRepository binaryContentRepository; + + @Autowired + private UserStatusRepository userStatusRepository; + + @Autowired + private BinaryContentStorage binaryContentStorage; + + @Test + @DisplayName("사용자 등록 프로세스가 모든 계층에서 올바르게 동작해야 한다.") + void completeCreateUserIntegration() throws IOException { + + // given + UserRequestDto request = new UserRequestDto("test", "test@test.com", "pwd1234"); + byte[] imageBytes = "test".getBytes(StandardCharsets.UTF_8); + BinaryContentDto profile = new BinaryContentDto("profile.png", 3L, "image/png", imageBytes); + + // when + UserResponseDto result = userService.create(request, profile); + + // then + assertNotNull(result); + Optional foundUser = userRepository.findById(result.id()); + assertTrue(foundUser.isPresent(), "유저가 저장되어야 한다."); + assertEquals("test", foundUser.get().getUsername()); + Optional status = userStatusRepository.findByUserId(result.id()); + assertTrue(status.isPresent(), "유저 1명의 UserStatus가 생성되어야 한다."); + BinaryContent userProfile = foundUser.get().getProfile(); + assertNotNull(userProfile, "프로필 사진이 등록되어야 한다."); + assertEquals("profile.png", userProfile.getFileName()); + byte[] data = binaryContentStorage.get(userProfile.getId()).readAllBytes(); + assertArrayEquals(imageBytes, data); + } + + @Test + @DisplayName("사용자 삭제 프로세스가 모든 계층에서 올바르게 동작해야 한다") + void completeDeleteUserIntegration() { + + // given + BinaryContent profile = new BinaryContent("profile.png", 3L, "image/png"); + User user = User.builder() + .username("test") + .email("test@test.com") + .password("pwd1234") + .profile(profile) + .build(); + + UserStatus userStatus = UserStatus.builder() + .user(user) + .lastActiveAt(Instant.now()) + .build(); + + user.updateStatus(userStatus); + + User savedUser = userRepository.save(user); + BinaryContent savedProfile = binaryContentRepository.save(profile); + userStatusRepository.save(userStatus); + + UUID userId = savedUser.getId(); + UUID profileId = savedProfile.getId(); + + // when + userService.deleteById(userId); + + // then + Optional foundUser = userRepository.findById(userId); + assertTrue(foundUser.isEmpty()); + Optional foundProfile = binaryContentRepository.findById(profileId); + assertTrue(foundProfile.isEmpty(), "삭제된 유저의 프로필 사진도 삭제되어야 한다."); + Optional foundUserStatus = userStatusRepository.findByUserId(userId); + assertTrue(foundUserStatus.isEmpty(), "삭제된 유저의 UserStatus도 삭제되어야 한다."); + } + + /* + 테스트 종료 후 binaryStorage 테스트 파일 삭제 처리 + */ + @AfterEach + void cleanUpStorage() throws IOException { + Path testRoot = Paths.get("./binaryTest"); + if (Files.exists(testRoot)) { + Files.walk(testRoot) + .sorted((a, b) -> b.compareTo(a)) // 파일 먼저, 그 다음 디렉토리 삭제 + .forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + // 무시 또는 로깅 + } + }); + } + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java new file mode 100644 index 000000000..6d958e323 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java @@ -0,0 +1,70 @@ +package com.sprint.mission.discodeit.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.config.JpaConfig; +import com.sprint.mission.discodeit.dto.auth.LoginDto; +import com.sprint.mission.discodeit.dto.user.UserResponseDto; +import com.sprint.mission.discodeit.service.AuthService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = AuthController.class, + excludeAutoConfiguration = {JpaConfig.class}) +@DisplayName("UserController 슬라이스 테스트") +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private AuthService authService; + + @Test + @DisplayName("등록된 유저로 로그인 시도를 하면 정상적으로 처리되어야 한다.") + void givenValidLoginDto_whenLogin_thenReturnLoggedInUserResponse() throws Exception { + + // given + LoginDto request = new LoginDto("test", "pwd12345"); + + UUID userId = UUID.randomUUID(); + UserResponseDto loggedInUser = new UserResponseDto( + userId, + "test", + "test@test.com", + null, + true + ); + + given(authService.login(any(LoginDto.class))).willReturn(loggedInUser); + + // when + ResultActions result = mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.username").value("test")) + .andExpect(jsonPath("$.email").value("test@test.com")) + .andExpect(jsonPath("$.online").value(true)); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java new file mode 100644 index 000000000..aaad0a020 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java @@ -0,0 +1,122 @@ +package com.sprint.mission.discodeit.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.config.JpaConfig; +import com.sprint.mission.discodeit.dto.channel.ChannelResponseDto; +import com.sprint.mission.discodeit.dto.channel.PrivateChannelDto; +import com.sprint.mission.discodeit.dto.channel.PublicChannelDto; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.service.basic.BasicChannelService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = ChannelController.class, + excludeAutoConfiguration = {JpaConfig.class}) +@DisplayName("ChannelController 슬라이스 테스트") +class ChannelControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private BasicChannelService channelService; + + @Test + @DisplayName("Public Channel 요청이 올바르게 처리되어야 한다.") + void givenValidPublicChannelDto_whenCreatePublicChannel_thenReturnCreatedChannelResponse() + throws Exception { + + // given + UUID channelId = UUID.randomUUID(); + PublicChannelDto request = new PublicChannelDto("public", "test channel"); + ChannelResponseDto expectedResponse = new ChannelResponseDto(channelId, ChannelType.PUBLIC, "public", + "test channel", List.of(), null); + + given(channelService.createPublicChannel(any(PublicChannelDto.class))) + .willReturn(expectedResponse); + + // when + ResultActions result = mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(channelId.toString())) + .andExpect(jsonPath("$.name").value("public")) + .andExpect(jsonPath("$.description").value("test channel")); + verify(channelService).createPublicChannel(argThat(req -> + req.name().equals("public") && + req.description().equals("test channel") + )); + } + + @Test + @DisplayName("Private Channel 요청이 올바르게 처리되어야 한다.") + void givenValidPrivateChannelDto_whenCreatePrivateChannel_thenReturnCreatedChannelResponse() + throws Exception { + + // given + UUID channelId = UUID.randomUUID(); + + PrivateChannelDto request = new PrivateChannelDto(List.of(UUID.randomUUID(), UUID.randomUUID())); + + ChannelResponseDto expectedResponse = new ChannelResponseDto(channelId, ChannelType.PRIVATE, null, + null, List.of(), null); + + given(channelService.createPrivateChannel(any(PrivateChannelDto.class))) + .willReturn(expectedResponse); + + // when + ResultActions result = mockMvc.perform(post("/api/channels/private") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isCreated()) + .andExpect(jsonPath("$.type").value(ChannelType.PRIVATE.toString())); + verify(channelService).createPrivateChannel(request); + } + + @Test + @DisplayName("Public Channel 생성 시 채널 이름이 누락되면 400 Bad Request가 발생해야 한다.") + void givenMissingRequiredField_whenCreatePublicChannel_thenReturnBadRequestWithValidationError() + throws Exception { + + // given + PublicChannelDto request = new PublicChannelDto(null, "test channel"); + + // when + ResultActions result = mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(request))); + + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.message").value("유효성 검사 실패")); + verify(channelService, never()).createPublicChannel(request); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java new file mode 100644 index 000000000..142ae59f4 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java @@ -0,0 +1,128 @@ +package com.sprint.mission.discodeit.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.config.JpaConfig; +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto; +import com.sprint.mission.discodeit.dto.message.MessageRequestDto; +import com.sprint.mission.discodeit.dto.message.MessageResponseDto; +import com.sprint.mission.discodeit.dto.user.UserResponseDto; +import com.sprint.mission.discodeit.service.basic.BasicMessageService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = MessageController.class, + excludeAutoConfiguration = {JpaConfig.class}) +@DisplayName("MessageController 슬라이스 테스트") +class MessageControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private BasicMessageService messageService; + + @Test + @DisplayName("메시지 생성 요청이 올바르게 처리되어야 한다.") + void givenValidUserRequest_whenCreateUser_thenReturnCreatedUserResponse() throws Exception { + + // given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + UUID messageId = UUID.randomUUID(); + + UserResponseDto author = new UserResponseDto(authorId, "test", "test@test.com", + null, true); + + MessageRequestDto request = new MessageRequestDto("Hello", channelId, authorId); + BinaryContentResponseDto file = new BinaryContentResponseDto(UUID.randomUUID(), "image.png", 3L, + "image/png"); + MessageResponseDto expectedResponse = new MessageResponseDto(messageId, Instant.now(), Instant.now(), + "Hello", channelId, author, List.of(file)); + + MockMultipartFile messageCreateRequest = new MockMultipartFile( + "messageCreateRequest", + "", + "application/json", + objectMapper.writeValueAsBytes(request) + ); + + // 가상의 파일 첨부 + MockMultipartFile attachment = new MockMultipartFile( + "attachments", + "image.png", + "image/png", + new byte[]{1, 2, 3} + ); + + given(messageService.create(any(MessageRequestDto.class), any())) + .willReturn(expectedResponse); + + // when + ResultActions result = mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequest) + .file(attachment) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)); + + // then + result.andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(messageId.toString())) + .andExpect(jsonPath("$.content").value("Hello")) + .andExpect(jsonPath("$.author.id").value(authorId.toString())) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.attachments[0].fileName").value("image.png")) + .andExpect(jsonPath("$.attachments[0].contentType").value("image/png")) + .andExpect(jsonPath("$.attachments[0].size").value(3)); + verify(messageService).create(argThat(req -> + req.authorId().equals(authorId) && + req.channelId().equals(channelId) + ), any()); + } + + @Test + @DisplayName("필수 항목이 누락되면 400 Bad Request가 발생해야 한다.") + void givenMissingRequiredField_whenCreateMessage_thenReturnBadRequestWithValidationError() + throws Exception { + + // given - 사용자 ID 누락 + MessageRequestDto request = new MessageRequestDto("Hello", UUID.randomUUID(), null); + + MockMultipartFile messageCreateRequest = new MockMultipartFile( + "messageCreateRequest", + "", + "application/json", + objectMapper.writeValueAsBytes(request) + ); + + // when + ResultActions result = mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequest) + .contentType(MediaType.MULTIPART_FORM_DATA)); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.message").value("유효성 검사 실패")); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java new file mode 100644 index 000000000..eecadacc8 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java @@ -0,0 +1,121 @@ +package com.sprint.mission.discodeit.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.config.JpaConfig; +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentDto; +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto; +import com.sprint.mission.discodeit.dto.user.UserRequestDto; +import com.sprint.mission.discodeit.dto.user.UserResponseDto; +import com.sprint.mission.discodeit.service.basic.BasicUserService; +import com.sprint.mission.discodeit.service.basic.BasicUserStatusService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = UserController.class, + excludeAutoConfiguration = {JpaConfig.class}) +@DisplayName("UserController 슬라이스 테스트") +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private BasicUserService userService; + + @MockitoBean + private BasicUserStatusService userStatusService; + + @Test + @DisplayName("사용자 생성 요청이 올바르게 처리되어야 한다.") + void givenValidUserRequest_whenCreateUser_thenReturnCreatedUserResponse() + throws Exception { + + // given + UUID userId = UUID.randomUUID(); + UserRequestDto request = new UserRequestDto("test", "test@test.com", "pwd1234"); + BinaryContentResponseDto profileImage = new BinaryContentResponseDto(UUID.randomUUID(), "profile.png", 3L, + "image/png"); + + UserResponseDto expectedResponse = new UserResponseDto(userId, "test", "test@test.com", profileImage, null); + + MockMultipartFile userCreateRequest = new MockMultipartFile( + "userCreateRequest", + "", + "application/json", + objectMapper.writeValueAsBytes(request) + ); + + MockMultipartFile profile = new MockMultipartFile( + "profile", + "profile.png", + "image/png", + new byte[]{1, 2, 3} + ); + + given(userService.create(any(UserRequestDto.class), any(BinaryContentDto.class))) + .willReturn(expectedResponse); + + // when + ResultActions result = mockMvc.perform(multipart("/api/users") + .file(userCreateRequest) + .file(profile) + .contentType(MediaType.MULTIPART_FORM_DATA)); + + // then + result.andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.username").value("test")) + .andExpect(jsonPath("$.email").value("test@test.com")); + verify(userService).create(argThat(req -> + req.username().equals("test") && + req.email().equals("test@test.com") + ), any()); + } + + @Test + @DisplayName("필수 항목이 누락되면 400 Bad Request가 발생해야 한다.") + void givenMissingRequiredField_whenCreateUser_thenReturnBadRequestWithValidationError() + throws Exception { + + // given + UserRequestDto request = new UserRequestDto(null, "test@test.com", "pwd1234"); + + MockMultipartFile userCreateRequest = new MockMultipartFile( + "userCreateRequest", + "", + "application/json", + objectMapper.writeValueAsBytes(request) + ); + + // when + ResultActions result = mockMvc.perform(multipart("/api/users") + .file(userCreateRequest) + .contentType(MediaType.MULTIPART_FORM_DATA)); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.message").value("유효성 검사 실패")); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java new file mode 100644 index 000000000..40d37b507 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java @@ -0,0 +1,44 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DataJpaTest +@ActiveProfiles("test") +@DisplayName("ChannelRepository 기능 테스트") +class ChannelRepositoryTest { + + @Autowired + private ChannelRepository channelRepository; + + @Test + @DisplayName("채널을 등록하고 조회할 수 있어야 한다.") + void itShouldSaveAndFindChannel() { + + // given + Channel channel = Channel.builder() + .name("public") + .description("test channel") + .type(ChannelType.PUBLIC) + .build(); + + // when + Channel savedChannel = channelRepository.save(channel); + + // then + Optional foundChannel = channelRepository.findById(savedChannel.getId()); + assertTrue(foundChannel.isPresent(), "채널이 등록되어야 한다."); + assertEquals("public", foundChannel.get().getName()); + assertEquals("test channel", foundChannel.get().getDescription()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java new file mode 100644 index 000000000..ba67ebca1 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java @@ -0,0 +1,108 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.config.JpaConfig; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DataJpaTest +@Import(JpaConfig.class) +@ActiveProfiles("test") +@DisplayName("MessageRepository 기능 테스트") +class MessageRepositoryTest { + + @Autowired + private MessageRepository messageRepository; + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private UserRepository userRepository; + + private Channel savedChannel; + + @BeforeEach + void setUp() { + // given + Channel channel = Channel.builder() + .name("public") + .type(ChannelType.PUBLIC) + .build(); + + User user = User.builder() + .username("test") + .email("test@test.com") + .password("pwd1234") + .build(); + + savedChannel = channelRepository.save(channel); + User savedUser = userRepository.save(user); + + Message message1 = Message.builder() + .channel(channel) + .author(user) + .content("Hello") + .build(); + + Message message2 = Message.builder() + .channel(channel) + .author(user) + .content("Hi") + .build(); + + Message message3 = Message.builder() + .channel(channel) + .author(user) + .content("Nice") + .build(); + + Message message4 = Message.builder() + .channel(channel) + .author(user) + .content("Wow") + .build(); + + messageRepository.saveAll(List.of(message1, message2, message3, message4)); + } + + @Test + @DisplayName("특정 채널의 이전 메시지를 시간 기준 내림차순으로 조회한다.") + void testFindByChannelIdAndCreatedAtLessThanOrderByCreatedAtDesc() { + + // when + List result = messageRepository.findByChannelIdAndCreatedAtLessThanOrderByCreatedAtDesc( + savedChannel.getId(), Instant.now(), PageRequest.of(0, 2) + ); + + // then + assertEquals(2, result.size()); + assertEquals("Wow", result.get(0).getContent()); + assertEquals("Nice", result.get(1).getContent()); + } + + @Test + @DisplayName("채널의 메시지를 페이지로 조회할 수 있어야 한다.") + void testFindPageByChannelId() { + + // when + List page1 = messageRepository.findPageByChannelId(savedChannel.getId(), PageRequest.of(0, 2)); + + // then + assertEquals(2, page1.size()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java new file mode 100644 index 000000000..6659751a2 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java @@ -0,0 +1,103 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.config.JpaConfig; +import com.sprint.mission.discodeit.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@Import(JpaConfig.class) +@ActiveProfiles("test") +@DisplayName("UserRepository 기능 테스트") +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + // 테스트에 사용할 더미 데이터 + @BeforeEach + void setUp() { + + // given + User user1 = User.builder() + .username("test1") + .email("test1@test.com") + .password("pwd1234") + .build(); + + User user2 = User.builder() + .username("test2") + .email("test2@test.com") + .password("pwd12345") + .build(); + + userRepository.save(user1); + userRepository.save(user2); + } + + @Test + @DisplayName("사용자명으로 사용자를 조회할 수 있어야 한다.") + void givenUsername_whenFindByUsername_thenReturnUser() { + + // when + Optional foundUser = userRepository.findByUsername("test1"); + + // then + assertTrue(foundUser.isPresent()); + assertEquals("test1", foundUser.get().getUsername()); + } + + @Test + @DisplayName("이메일로 사용자를 조회할 수 있어야 한다.") + void givenEmail_whenFindByEmail_thenReturnUser() { + + // when + Optional foundUser = userRepository.findByEmail("test2@test.com"); + + // then + assertTrue(foundUser.isPresent()); + assertEquals("test2@test.com", foundUser.get().getEmail()); + } + + @Test + @DisplayName("존재하는 사용자명 여부를 확인할 수 있어야 한다.") + void givenUsername_whenExistsByUsername_thenReturnTrue() { + + // when + boolean exist = userRepository.existsByUsername("test2"); + + // then + assertTrue(exist); + } + + @Test + @DisplayName("존재하지 않는 사용자명 여부를 확인할 수 있어야 한다.") + void givenNonexistentUsername_whenExistsByUsername_thenReturnFalse() { + + // when + boolean exist = userRepository.existsByUsername("notExistName"); + + // then + assertFalse(exist); + } + + @Test + @DisplayName("존재하는 이메일 여부를 확인할 수 있어야 한다.") + void givenUsername_whenExistsByEmail_thenReturnTrue() { + + // when + boolean exist = userRepository.existsByEmail("test1@test.com"); + + // then + assertTrue(exist); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java new file mode 100644 index 000000000..ba13379aa --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java @@ -0,0 +1,409 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.channel.ChannelResponseDto; +import com.sprint.mission.discodeit.dto.channel.PrivateChannelDto; +import com.sprint.mission.discodeit.dto.channel.PublicChannelDto; +import com.sprint.mission.discodeit.dto.channel.PublicChannelUpdateDto; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.NotFoundChannelException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.mapper.ChannelMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +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.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Channel 서비스 단위 테스트") +class BasicChannelServiceTest { + + @InjectMocks + private BasicChannelService channelService; + + @Mock + private ChannelRepository channelRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ReadStatusRepository readStatusRepository; + + @Mock + private ChannelMapper channelMapper; + + @Test + @DisplayName("정상적인 Public 채널 생성 요청 시 올바른 비즈니스 로직이 수행되어야 한다.") + void should_create_public_channel_when_valid_request() { + + // given + UUID channelId = UUID.randomUUID(); + String name = "public1"; + String description = "It's public channel"; + + PublicChannelDto request = new PublicChannelDto(name, description); + + Channel channel = Channel.builder() + .name(name) + .description(description) + .type(ChannelType.PUBLIC) + .build(); + + ReflectionTestUtils.setField(channel, "id", channelId); + + ChannelResponseDto response = new ChannelResponseDto(channel.getId(), ChannelType.PUBLIC, + name, description, null, null); + + given(channelRepository.save(any(Channel.class))).willReturn(channel); + given(channelMapper.toDto(any(Channel.class))).willReturn(response); + + // when + ChannelResponseDto result = channelService.createPublicChannel(request); + + // then + assertNotNull(result); + assertEquals(name, result.name()); + assertEquals(description, result.description()); + assertEquals(ChannelType.PUBLIC, result.type()); + } + + @Test + @DisplayName("정상적인 Private 채널 생성 요청 시 올바른 비즈니스 로직이 수행되어야 한다.") + void should_create_private_channel_when_valid_user_ids() { + + // given + UUID userId1 = UUID.randomUUID(); + UUID userId2 = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + + PrivateChannelDto request = new PrivateChannelDto(List.of(userId1, userId2)); + + Channel channel = new Channel(); + ReflectionTestUtils.setField(channel, "id", channelId); + + User user1 = User.builder() + .username("user1") + .email("user1@example.com") + .password("pwd1") + .build(); + + User user2 = User.builder() + .username("user2") + .email("user2@example.com") + .password("pwd2") + .build(); + + ChannelResponseDto response = new ChannelResponseDto(channelId, ChannelType.PRIVATE, null, + null, List.of(), null); + + ReflectionTestUtils.setField(user1, "id", userId1); + ReflectionTestUtils.setField(user2, "id", userId2); + + given(channelRepository.save(any(Channel.class))).willReturn(channel); + given(userRepository.findById(userId1)).willReturn(Optional.of(user1)); + given(userRepository.findById(userId2)).willReturn(Optional.of(user2)); + given(channelMapper.toDto(channel)).willReturn(response); + + // when + ChannelResponseDto result = channelService.createPrivateChannel(request); + + // then + assertNotNull(result); + assertEquals(channelId, result.id()); + assertEquals(ChannelType.PRIVATE, result.type()); + verify(channelRepository).save(any(Channel.class)); + verify(readStatusRepository).saveAll(anyList()); + verify(channelMapper).toDto(channel); + } + + @Test + @DisplayName("등록된 채널 ID로 조회하면 올바르게 조회되어야 한다.") + void should_return_channel_when_channel_exists_by_id() { + + // given + String channelName = "channel1"; + String channelDescription = "test channel"; + UUID channelId = UUID.randomUUID(); + + Channel channel = Channel.builder() + .name(channelName) + .description(channelDescription) + .type(ChannelType.PUBLIC) + .build(); + + ReflectionTestUtils.setField(channel, "id", channelId); + + ChannelResponseDto expectedChannel = new ChannelResponseDto(channelId, ChannelType.PUBLIC, channelName, + channelDescription, List.of(), null); + + given(channelRepository.findById(channelId)).willReturn(Optional.of(channel)); + given(channelMapper.toDto(channel)).willReturn(expectedChannel); + + // when + ChannelResponseDto result = channelService.findById(channelId); + + // then + assertEquals(expectedChannel, result); + verify(channelRepository).findById(channelId); + verify(channelMapper).toDto(channel); + } + + @Test + @DisplayName("등록되지 않은 채널 ID로 조회하면 NotFoundChannelException이 발생해야 한다.") + void should_throw_not_found_exception_when_channel_does_not_exist_by_id() { + + // given + UUID notExistId = UUID.randomUUID(); + given(channelRepository.findById(notExistId)).willReturn(Optional.empty()); + + // when + Throwable thrown = catchThrowable(() -> channelService.findById(notExistId)); + + // then + assertThat(thrown) + .isInstanceOf(NotFoundChannelException.class) + .hasMessageContaining("채널"); + verify(channelRepository).findById(notExistId); + } + + @Test + @DisplayName("등록된 사용자 ID로 조회하면 사용자의 참여 채널과 공개 채널이 모두 반환되어야 한다.") + void should_return_user_channels_and_public_channels_when_user_exists() { + + // given + UUID userId = UUID.randomUUID(); + UUID publicChannelId = UUID.randomUUID(); + UUID privateChannelId = UUID.randomUUID(); + UUID notRelatedChannelId = UUID.randomUUID(); + + User user = User.builder() + .username("test") + .email("test@test.com") + .password("test123") + .build(); + + Channel publicChannel = Channel.builder() + .name("public") + .description("test channel") + .type(ChannelType.PUBLIC) + .build(); + + Channel privateChannel = Channel.builder() + .type(ChannelType.PRIVATE) + .build(); + + Channel notRelatedChannel = Channel.builder() + .type(ChannelType.PRIVATE) + .build(); + + ReflectionTestUtils.setField(user, "id", userId); + ReflectionTestUtils.setField(publicChannel, "id", publicChannelId); + ReflectionTestUtils.setField(privateChannel, "id", privateChannelId); + ReflectionTestUtils.setField(notRelatedChannel, "id", notRelatedChannelId); + + ReadStatus readStatus = ReadStatus.builder() + .channel(privateChannel) + .user(user) + .build(); + ReflectionTestUtils.setField(readStatus, "id", UUID.randomUUID()); + + List allChannels = List.of(publicChannel, privateChannel, notRelatedChannel); + List readStatuses = List.of(readStatus); + + ChannelResponseDto publicChannelDto = new ChannelResponseDto(publicChannelId, ChannelType.PUBLIC, "public", + "test channel", null, null); + + ChannelResponseDto privateChannelDto = new ChannelResponseDto(privateChannelId, ChannelType.PRIVATE, null, + null, List.of(), null); + + given(readStatusRepository.findAllByUserId(userId)).willReturn(readStatuses); + given(channelRepository.findAll()).willReturn(allChannels); + given(channelMapper.toDto(publicChannel)).willReturn(publicChannelDto); + given(channelMapper.toDto(privateChannel)).willReturn(privateChannelDto); + + // when + List result = channelService.findAllByUserId(userId); + + // then + assertEquals(2, result.size()); + assertTrue(result.contains(publicChannelDto)); + assertTrue(result.contains(privateChannelDto)); + assertFalse(result.stream().anyMatch(dto -> dto.id().equals(notRelatedChannelId))); + verify(readStatusRepository).findAllByUserId(userId); + verify(channelRepository).findAll(); + verify(channelMapper).toDto(publicChannel); + verify(channelMapper).toDto(privateChannel); + verifyNoMoreInteractions(channelMapper); // 참여하지 않은 비공개 채널은 변환 X + } + + @Test + @DisplayName("등록되지 않은 사용자 ID로 조회하면 Public 채널만 조회되어야한다.") + void should_return_only_public_channels_when_user_does_not_exist() { + + // given + UUID notExistId = UUID.randomUUID(); + UUID publicChannelId = UUID.randomUUID(); + UUID privateChannelId = UUID.randomUUID(); + + Channel publicChannel = Channel.builder() + .name("public") + .description("test channel") + .type(ChannelType.PUBLIC) + .build(); + + Channel privateChannel = Channel.builder() + .type(ChannelType.PRIVATE) + .build(); + + ReflectionTestUtils.setField(publicChannel, "id", publicChannelId); + ReflectionTestUtils.setField(privateChannel, "id", privateChannelId); + + List allChannels = List.of(publicChannel, privateChannel); + + ChannelResponseDto publicChannelDto = new ChannelResponseDto(publicChannelId, ChannelType.PUBLIC, "public", + "test channel", null, null); + + given(readStatusRepository.findAllByUserId(notExistId)).willReturn(List.of()); + given(channelRepository.findAll()).willReturn(allChannels); + given(channelMapper.toDto(publicChannel)).willReturn(publicChannelDto); + + // when + List result = channelService.findAllByUserId(notExistId); + + // then + assertEquals(1, result.size()); + assertTrue(result.contains(publicChannelDto)); + assertFalse(result.stream().anyMatch(dto -> dto.id().equals(privateChannelId))); + verify(readStatusRepository).findAllByUserId(notExistId); + verify(channelRepository).findAll(); + verify(channelMapper).toDto(publicChannel); + verifyNoMoreInteractions(channelMapper); + } + + @Test + @DisplayName("정상적인 요청으로 Public 채널을 업데이트하면 올바른 비즈니스 로직이 수행되어야한다.") + void should_update_public_channel_when_valid_update_request() { + + // given + UUID channelId = UUID.randomUUID(); + String newName = "public"; + String newDescription = "It's public channel"; + + Channel existingChannel = Channel.builder() + .name("public channel") + .description("it's public channel") + .type(ChannelType.PUBLIC) + .build(); + + ReflectionTestUtils.setField(existingChannel, "id", channelId); + + PublicChannelUpdateDto updateRequest = new PublicChannelUpdateDto(newName, newDescription); + + ChannelResponseDto expectedResponse = new ChannelResponseDto(channelId, ChannelType.PUBLIC, + newName, newDescription, List.of(), null); + + given(channelRepository.findById(channelId)).willReturn(Optional.of(existingChannel)); + given(channelRepository.save(any(Channel.class))).willReturn(existingChannel); + given(channelMapper.toDto(any(Channel.class))).willReturn(expectedResponse); + + // when + ChannelResponseDto result = channelService.update(channelId, updateRequest); + + // then + assertNotNull(result); + assertEquals(newName, result.name()); + assertEquals(newDescription, result.description()); + verify(channelRepository).save(any(Channel.class)); + } + + @Test + @DisplayName("Private 채널을 수정하려하면 PrivateChannelUpdateException이 발생해야한다.") + void should_throw_exception_when_try_to_update_private_channel() { + + // given + UUID channelId = UUID.randomUUID(); + Channel channel = Channel.builder() + .type(ChannelType.PRIVATE) + .build(); + + ReflectionTestUtils.setField(channel, "id", channelId); + + PublicChannelUpdateDto updateRequest = new PublicChannelUpdateDto("new Channel", "It's new Channel"); + + given(channelRepository.findById(channelId)).willReturn(Optional.of(channel)); + + // when + Throwable thrown = catchThrowable(() -> channelService.update(channelId, updateRequest)); + + // then + assertThat(thrown) + .isInstanceOf(PrivateChannelUpdateException.class) + .hasMessageContaining("Private"); + verify(channelRepository).findById(channelId); + verify(userRepository, never()).save(any()); + } + + + @Test + @DisplayName("등록된 채널 ID로 삭제를 시도하면 정상적으로 삭제되어야한다.") + void should_delete_channel_when_channel_exists_by_id() { + + // given + UUID channelId = UUID.randomUUID(); + + Channel channel = Channel.builder() + .name("public channel") + .description("It's public channel") + .type(ChannelType.PUBLIC) + .build(); + + ReflectionTestUtils.setField(channel, "id", channelId); + + given(channelRepository.findById(channelId)).willReturn(Optional.of(channel)); + + // when + channelService.deleteById(channelId); + + // then + verify(channelRepository).deleteById(channelId); + } + + @Test + @DisplayName("등록되지 않은 채널 ID로 삭제를 시도하면 NotFoundChannelException이 발생해야 한다.") + void should_throw_not_found_exception_when_try_to_delete_nonexistent_channel() { + + // given + UUID notExistId = UUID.randomUUID(); + given(channelRepository.findById(notExistId)).willReturn(Optional.empty()); + + // when + Throwable thrown = catchThrowable(() -> channelService.deleteById(notExistId)); + + // then + assertThat(thrown) + .isInstanceOf(NotFoundChannelException.class) + .hasMessageContaining("채널"); + verify(channelRepository).findById(notExistId); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java new file mode 100644 index 000000000..1941d80dc --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java @@ -0,0 +1,469 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentDto; +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto; +import com.sprint.mission.discodeit.dto.message.MessageRequestDto; +import com.sprint.mission.discodeit.dto.message.MessageResponseDto; +import com.sprint.mission.discodeit.dto.message.MessageUpdateDto; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.entity.*; +import com.sprint.mission.discodeit.exception.channel.NotFoundChannelException; +import com.sprint.mission.discodeit.exception.message.NotFoundMessageException; +import com.sprint.mission.discodeit.exception.user.NotFoundUserException; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.mapper.struct.BinaryContentStructMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +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.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Message 서비스 단위 테스트") +class BasicMessageServiceTest { + + @InjectMocks + private BasicMessageService messageService; + + @Mock + private MessageRepository messageRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChannelRepository channelRepository; + + @Mock + private BinaryContentRepository binaryContentRepository; + + @Mock + private MessageMapper messageMapper; + + @Mock + private BinaryContentStorage binaryContentStorage; + + @Mock + private BinaryContentStructMapper binaryContentMapper; + + @Test + @DisplayName("정상적인 Message 생성 시 올바른 비즈니스 로직이 수행되어야 한다.") + void givenValidRequest_whenCreateMessage_thenCreateSuccessfully() { + + // given + String content = "Hello"; + UUID messageId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + + byte[] imageBytes = new byte[]{1, 2, 3}; + + BinaryContentDto binaryContentDto = new BinaryContentDto("attachment.png", 3L, + "image/png", imageBytes); + + BinaryContent binaryContent = new BinaryContent("attachment.png", 3L, "image/png"); + + UUID attachmentId = UUID.randomUUID(); + ReflectionTestUtils.setField(binaryContent, "id", attachmentId); + + MessageRequestDto request = new MessageRequestDto(content, channelId, authorId); + + User user = User.builder() + .username("test") + .email("test@test.com") + .password("pwd1234") + .build(); + + Channel channel = Channel.builder() + .name("public") + .description("test channel") + .type(ChannelType.PUBLIC) + .build(); + + Message message = Message.builder() + .author(user) + .channel(channel) + .content(content) + .attachments(List.of(binaryContent)) + .build(); + + ReflectionTestUtils.setField(user, "id", authorId); + ReflectionTestUtils.setField(channel, "id", channelId); + ReflectionTestUtils.setField(message, "id", messageId); + + BinaryContentResponseDto attachment = new BinaryContentResponseDto(attachmentId, "attachment.png", 3L, + "image/png"); + + MessageResponseDto messageResponseDto = new MessageResponseDto(messageId, Instant.now(), Instant.now(), + content, channelId, null, List.of(attachment)); + + given(userRepository.findById(authorId)).willReturn(Optional.of(user)); + given(channelRepository.findById(channelId)).willReturn(Optional.of(channel)); + given(binaryContentMapper.toEntity(binaryContentDto)).willReturn(binaryContent); + given(messageRepository.save(any(Message.class))).willReturn(message); + given(messageMapper.toDto(any(Message.class))).willReturn(messageResponseDto); + + // when + MessageResponseDto result = messageService.create(request, List.of(binaryContentDto)); + + // then + assertNotNull(result); + assertEquals(content, result.content()); + assertEquals(channelId, result.channelId()); + assertEquals(List.of(attachment), result.attachments()); + verify(userRepository).findById(authorId); + verify(channelRepository).findById(channelId); + verify(binaryContentRepository).saveAll(List.of(binaryContent)); + verify(binaryContentStorage).put(attachmentId, imageBytes); + } + + @Test + @DisplayName("존재하지 않는 사용자 ID로 메시지를 전송하면 NotFoundUserException이 발생해야 한다.") + void givenInvalidUserId_whenCreateMessage_thenThrowNotFoundUserException() { + + // given + UUID notExistUserId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + + MessageRequestDto request = new MessageRequestDto("Hello", channelId, notExistUserId); + + given(userRepository.findById(notExistUserId)).willReturn(Optional.empty()); + + // when + Throwable thrown = catchThrowable(() -> messageService.create(request, null)); + + // then + assertThat(thrown) + .isInstanceOf(NotFoundUserException.class) + .hasMessageContaining("사용자"); + verify(messageRepository, never()).save(any()); + } + + @Test + @DisplayName("존재하지 않는 채널 ID로 메시지를 전송하면 NotFoundChannelException이 발생해야 한다.") + void givenInvalidChannelId_whenCreateMessage_thenThrowNotFoundChannelException() { + + // given + UUID userId = UUID.randomUUID(); + UUID notExistChannelId = UUID.randomUUID(); + + MessageRequestDto request = new MessageRequestDto("Hello", notExistChannelId, userId); + + User user = User.builder() + .username("test") + .email("test@test.com") + .password("pwd1234") + .build(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(channelRepository.findById(notExistChannelId)).willReturn(Optional.empty()); + + // when + Throwable thrown = catchThrowable(() -> messageService.create(request, null)); + + // then + assertThat(thrown) + .isInstanceOf(NotFoundChannelException.class) + .hasMessageContaining("채널"); + verify(messageRepository, never()).save(any()); + } + + @Test + @DisplayName("메시지 ID로 메시지를 조회하면 정상적으로 조회되어야 한다.") + void givenValidMessageId_whenFindMessage_thenReturnDto() { + + // given + String content = "Hello"; + UUID messageId = UUID.randomUUID(); + + User user = User.builder() + .username("test") + .email("test@test.com") + .password("pwd1234") + .build(); + + Channel channel = Channel.builder() + .name("public") + .description("test channel") + .type(ChannelType.PUBLIC) + .build(); + + Message message = Message.builder() + .content(content) + .author(user) + .channel(channel) + .attachments(List.of()) + .build(); + + ReflectionTestUtils.setField(message, "id", messageId); + ReflectionTestUtils.setField(user, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(channel, "id", UUID.randomUUID()); + + MessageResponseDto expectedDto = new MessageResponseDto( + messageId, Instant.now(), Instant.now(), + content, channel.getId(), null, List.of() + ); + + given(messageRepository.findById(messageId)).willReturn(Optional.of(message)); + given(messageMapper.toDto(message)).willReturn(expectedDto); + + // when + MessageResponseDto result = messageService.findById(messageId); + + // then + assertNotNull(result); + assertEquals(content, result.content()); + assertEquals(channel.getId(), result.channelId()); + verify(messageRepository).findById(messageId); + verify(messageMapper).toDto(message); + } + + @Test + @DisplayName("등록되지 않은 ID로 메시지를 조회하면 NotFoundMessageException이 발생해야 한다.") + void givenInvalidMessageId_whenFindMessage_thenThrowNotFoundMessageException() { + + // given + UUID notExistId = UUID.randomUUID(); + + given(messageRepository.findById(notExistId)).willReturn(Optional.empty()); + + // when + Throwable thrown = catchThrowable(() -> messageService.findById(notExistId)); + + // then + assertThat(thrown) + .isInstanceOf(NotFoundMessageException.class) + .hasMessageContaining("메시지"); + verify(messageRepository).findById(notExistId); + verifyNoInteractions(messageMapper); + } + + @Test + @DisplayName("채널 ID로 메시지를 페이지네이션 조회할 때 cursor가 null이면 최신 메시지를 반환해야 한다.") + void givenNullCursor_whenFindMessagesByChannelId_thenReturnFirstPage() { + // given + UUID channelId = UUID.randomUUID(); + UUID messageId1 = UUID.randomUUID(); + UUID messageId2 = UUID.randomUUID(); + + int size = 2; + Pageable pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Pageable extendedPageable = PageRequest.of(0, size + 1, Sort.by(Sort.Direction.DESC, "createdAt")); + + Channel channel = Channel.builder().name("channel").type(ChannelType.PUBLIC).build(); + ReflectionTestUtils.setField(channel, "id", channelId); + + Instant now = Instant.now(); + Instant t1 = now.minusSeconds(10); + Instant t2 = now.minusSeconds(20); + + Message msg1 = Message.builder().content("Hello").channel(channel).build(); + Message msg2 = Message.builder().content("World").channel(channel).build(); + Message msg3 = Message.builder().content("NextPage").channel(channel).build(); + + ReflectionTestUtils.setField(msg1, "createdAt", now); + ReflectionTestUtils.setField(msg2, "createdAt", t1); + ReflectionTestUtils.setField(msg3, "createdAt", t2); + ReflectionTestUtils.setField(msg1, "id", messageId1); + ReflectionTestUtils.setField(msg2, "id", messageId2); + + List allMessages = List.of(msg1, msg2, msg3); // size + 1개 + + MessageResponseDto dto1 = new MessageResponseDto(messageId1, Instant.now(), Instant.now(), "Hello", channelId, null, List.of()); + MessageResponseDto dto2 = new MessageResponseDto(messageId2, Instant.now(), Instant.now(), "World", channelId, null, List.of()); + + given(messageRepository.findPageByChannelId(channelId, extendedPageable)).willReturn(allMessages); + given(messageMapper.toDto(msg1)).willReturn(dto1); + given(messageMapper.toDto(msg2)).willReturn(dto2); + + // when + PageResponse result = messageService.findAllByChannelId(channelId, null, pageable); + + // then + assertEquals(size, result.content().size()); + assertTrue(result.hasNext()); + assertEquals(dto1, result.content().get(0)); + assertEquals(dto2, result.content().get(1)); + verify(messageRepository).findPageByChannelId(channelId, extendedPageable); + verify(messageMapper).toDto(msg1); + verify(messageMapper).toDto(msg2); + } + + @Test + @DisplayName("cursor가 주어지면 해당 시간 이전 메시지를 최신순으로 페이지네이션 조회해야 한다.") + void givenCursor_whenFindMessagesByChannelId_thenReturnPreviousMessagesPage() { + // given + UUID channelId = UUID.randomUUID(); + UUID messageId1 = UUID.randomUUID(); + UUID messageId2 = UUID.randomUUID(); + + int size = 2; + Instant cursor = Instant.now(); // 기준 시간 + Instant t1 = cursor.minusSeconds(10); + Instant t2 = cursor.minusSeconds(20); + Instant t3 = cursor.minusSeconds(30); + + Pageable pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Pageable extendedPageable = PageRequest.of(0, size + 1, Sort.by(Sort.Direction.DESC, "createdAt")); + + Channel channel = Channel.builder().name("channel").type(ChannelType.PUBLIC).build(); + ReflectionTestUtils.setField(channel, "id", channelId); + + Message msg1 = Message.builder().content("Msg1").channel(channel).build(); + Message msg2 = Message.builder().content("Msg2").channel(channel).build(); + Message msg3 = Message.builder().content("Msg3").channel(channel).build(); + + ReflectionTestUtils.setField(msg1, "createdAt", t1); + ReflectionTestUtils.setField(msg2, "createdAt", t2); + ReflectionTestUtils.setField(msg3, "createdAt", t3); + ReflectionTestUtils.setField(msg1, "id", messageId1); + ReflectionTestUtils.setField(msg2, "id", messageId2); + + List messages = List.of(msg1, msg2, msg3); // size + 1개 + + MessageResponseDto dto1 = new MessageResponseDto(messageId1, t1, t1, "Msg1", channelId, null, List.of()); + MessageResponseDto dto2 = new MessageResponseDto(messageId2, t2, t2, "Msg2", channelId, null, List.of()); + + given(messageRepository.findByChannelIdAndCreatedAtLessThanOrderByCreatedAtDesc(channelId, cursor, extendedPageable)) + .willReturn(messages); + given(messageMapper.toDto(msg1)).willReturn(dto1); + given(messageMapper.toDto(msg2)).willReturn(dto2); + + // when + PageResponse result = messageService.findAllByChannelId(channelId, cursor, pageable); + + // then + assertEquals(size, result.content().size()); + assertTrue(result.hasNext()); + assertEquals(dto1, result.content().get(0)); + assertEquals(dto2, result.content().get(1)); + assertEquals(t2, result.nextCursor()); // 마지막 메시지의 createdAt + verify(messageRepository).findByChannelIdAndCreatedAtLessThanOrderByCreatedAtDesc(channelId, cursor, extendedPageable); + verify(messageMapper).toDto(msg1); + verify(messageMapper).toDto(msg2); + verifyNoMoreInteractions(messageMapper); // msg3는 변환 안됨 + } + + @Test + @DisplayName("등록된 메시지 ID로 메시지를 업데이트하면 올바른 비즈니스 로직이 수행되어야 한다.") + void givenValidMessageIdAndContent_whenUpdateMessage_thenUpdateSuccessfully() { + + // given + UUID messageId = UUID.randomUUID(); + + Message message = Message.builder() + .content("Hello") + .build(); + + ReflectionTestUtils.setField(message, "id", messageId); + + String newContent = "Hi"; + + Message updatedMessage = Message.builder() + .content(newContent) + .build(); + + MessageResponseDto expectedMessage = new MessageResponseDto(messageId, Instant.now(), Instant.now(), newContent, + null, null, List.of()); + + given(messageRepository.findById(messageId)).willReturn(Optional.of(message)); + given(messageMapper.toDto(any(Message.class))).willReturn(expectedMessage); + given(messageRepository.save(any(Message.class))).willReturn(updatedMessage); + + // when + MessageResponseDto result = messageService.updateContent(messageId, newContent); + + // then + assertNotNull(result); + assertEquals(expectedMessage, result); + verify(messageRepository).findById(messageId); + } + + @Test + @DisplayName("등록되지 않은 메시지 ID로 메시지를 업데이트하면 NotFoundMessageException이 발생해야 한다.") + void givenInvalidMessageId_whenUpdateMessage_thenThrowNotFoundMessageException() { + + // given + UUID notExistId = UUID.randomUUID(); + + given(messageRepository.findById(notExistId)).willReturn(Optional.empty()); + + // when + Throwable thrown = catchThrowable(() -> messageService.updateContent(notExistId, "hello")); + + // then + assertThat(thrown) + .isInstanceOf(NotFoundMessageException.class) + .hasMessageContaining("메시지"); + verify(messageRepository).findById(notExistId); + } + + @Test + @DisplayName("등록된 메시지 ID로 삭제하면 첨부파일도 삭제되어야 한다.") + void givenValidMessageId_whenDeleteMessage_thenDeleteWithAttachments() { + + // given + UUID messageId = UUID.randomUUID(); + BinaryContent attachment = new BinaryContent("attachment.jpg", 3L, + "image/jpeg"); + + ReflectionTestUtils.setField(attachment, "id", UUID.randomUUID()); + + Message message = Message.builder() + .attachments(List.of(attachment)) + .build(); + + ReflectionTestUtils.setField(message, "id", messageId); + + given(messageRepository.findById(messageId)).willReturn(Optional.of(message)); + + // when + messageService.deleteById(messageId); + + // then + verify(messageRepository).findById(messageId); + verify(binaryContentRepository).deleteById(message.getAttachments().get(0).getId()); + verify(messageRepository).deleteById(messageId); + } + + @Test + @DisplayName("등록되지 않은 메시지 ID로 삭제하면 NotFoundMessageException이 발생해야 한다.") + void givenInvalidMessageId_whenDeleteMessage_thenThrowNotFoundMessageException() { + + // given + UUID notExistId = UUID.randomUUID(); + given(messageRepository.findById(notExistId)).willReturn(Optional.empty()); + + // when + Throwable thrown = catchThrowable(() -> messageService.deleteById(notExistId)); + + // then + assertThat(thrown) + .isInstanceOf(NotFoundMessageException.class) + .hasMessageContaining("메시지"); + verify(messageRepository).findById(notExistId); + verifyNoMoreInteractions(binaryContentRepository); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java new file mode 100644 index 000000000..6b9bd81ce --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java @@ -0,0 +1,410 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentDto; +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto; +import com.sprint.mission.discodeit.dto.user.UserRequestDto; +import com.sprint.mission.discodeit.dto.user.UserResponseDto; +import com.sprint.mission.discodeit.dto.user.UserUpdateDto; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.exception.user.DuplicateEmailException; +import com.sprint.mission.discodeit.exception.user.DuplicateNameException; +import com.sprint.mission.discodeit.exception.user.NotFoundUserException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.mapper.struct.BinaryContentStructMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +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.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.not; +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("User 서비스 단위 테스트") +class BasicUserServiceTest { + + @InjectMocks + private BasicUserService userService; + + @Mock + private UserRepository userRepository; + + @Mock + private UserStatusRepository userStatusRepository; + + @Mock + private BinaryContentRepository binaryContentRepository; + + @Mock + private UserMapper userMapper; + + @Mock + private BinaryContentStorage binaryContentStorage; + + @Mock + private BinaryContentStructMapper binaryContentMapper; + + @Test + @DisplayName("정상적인 User 생성 시 올바른 비즈니스 로직이 수행되어야 한다.") + void givenValidRequestAndProfile_whenCreateUser_thenCreateSuccessfully() { + + // given + String username = "test"; + String email = "test@test.com"; + String password = "pwd1234"; + UserRequestDto request = new UserRequestDto(username, email, password); + byte[] imageBytes = new byte[]{1, 2, 3}; + + BinaryContentDto binaryContentDto = new BinaryContentDto("profile.png", 3L, + "image/png", imageBytes); + + BinaryContent binaryContent = new BinaryContent("profile.png", 3L, "image/png"); + + UUID profileId = UUID.randomUUID(); + ReflectionTestUtils.setField(binaryContent, "id", profileId); + + User user = User.builder() + .username(username) + .email(email) + .password(password) + .build(); + + UserStatus userStatus = UserStatus.builder() + .user(user) + .lastActiveAt(Instant.now()) + .build(); + + UUID userId = UUID.randomUUID(); + UUID userStatusId = UUID.randomUUID(); + + ReflectionTestUtils.setField(user, "id", userId); + ReflectionTestUtils.setField(userStatus, "id", userStatusId); + + BinaryContentResponseDto profile = new BinaryContentResponseDto(profileId, "profile.png", 3L, + "image/png"); + + UserResponseDto response = new UserResponseDto(userId, username, email, profile, null); + + given(binaryContentMapper.toEntity(binaryContentDto)).willReturn(binaryContent); + given(userRepository.save(any(User.class))).willReturn(user); + given(userMapper.toDto(user)).willReturn(response); + given(userRepository.existsByUsername(username)).willReturn(false); + given(userRepository.existsByEmail(email)).willReturn(false); + + // when + UserResponseDto result = userService.create(request, binaryContentDto); + + // then + assertNotNull(result); + assertEquals(username, result.username()); + assertEquals(email, result.email()); + assertEquals(profile, result.profile()); + verify(userStatusRepository).save(any(UserStatus.class)); + verify(binaryContentRepository).save(binaryContent); + verify(binaryContentStorage).put(profileId, imageBytes); + } + + @Test + @DisplayName("중복된 username이 존재하는 경우 DuplicateNameException이 발생해야 한다.") + void givenDuplicateUsername_whenCreateUser_thenThrowDuplicateNameException() { + + // given + String username = "test"; + String email = "test@test.com"; + String password = "pwd1234"; + + UserRequestDto request = new UserRequestDto(username, email, password); + given(userRepository.existsByUsername(username)).willReturn(true); + + // when + Throwable thrown = catchThrowable(() -> userService.create(request, null)); + + // then + assertThat(thrown) + .isInstanceOf(DuplicateNameException.class) + .hasMessageContaining("존재"); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("중복된 email이 존재하는 경우 DuplicateEmailException이 발생해야 한다.") + void givenDuplicateEmail_whenCreateUser_thenThrowDuplicateEmailException() { + + // given + String username = "test"; + String email = "test@test.com"; + String password = "pwd1234"; + + UserRequestDto request = new UserRequestDto(username, email, password); + given(userRepository.existsByEmail(email)).willReturn(true); + + // when + Throwable thrown = catchThrowable(() -> userService.create(request, null)); + + // then + assertThat(thrown) + .isInstanceOf(DuplicateEmailException.class) + .hasMessageContaining("존재"); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("등록된 사용자 ID로 조회하면 올바르게 조회되어야 한다.") + void givenRegisteredUserId_whenFindById_thenReturnUserResponseDto() { + + // given + String username = "test"; + String email = "test@test.com"; + String password = "pwd1234"; + UUID userId = UUID.randomUUID(); + + User user = User.builder() + .username(username) + .email(email) + .password(password) + .build(); + + ReflectionTestUtils.setField(user, "id", userId); + + UserStatus userStatus = UserStatus.builder() + .user(user) + .lastActiveAt(Instant.now()) + .build(); + + UserResponseDto expectedUser = new UserResponseDto(userId, username, email, + null, null); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(userStatusRepository.findByUserId(userId)).willReturn(Optional.of(userStatus)); + given(userMapper.toDto(user)).willReturn(expectedUser); + + // when + UserResponseDto actualUser = userService.findById(userId); + + // then + assertEquals(expectedUser, actualUser); + verify(userRepository).findById(userId); + verify(userStatusRepository).findByUserId(userId); + verify(userMapper).toDto(user); + } + + @Test + @DisplayName("등록되지 않은 ID로 사용자를 조회하면 NotFoundUserException이 발생해야한다.") + void givenNonexistentUserId_whenFindById_thenThrowNotFoundUserException() { + + // given + UUID notExistId = UUID.randomUUID(); + given(userRepository.findById(notExistId)).willReturn(Optional.empty()); + + // when + Throwable thrown = catchThrowable(() -> userService.findById(notExistId)); + + // then + assertThat(thrown) + .isInstanceOf(NotFoundUserException.class) + .hasMessageContaining("사용자"); + verify(userRepository).findById(notExistId); + verifyNoInteractions(userStatusRepository, userMapper); // 사용자 없으면 이후 로직 없어야 함 + } + + @Test + @DisplayName("정상적인 정보로 사용자를 업데이트하면 올바른 비즈니스 로직이 수행되어야 한다.") + void givenValidUpdateRequest_whenUpdateUser_thenUpdateSuccessfully() { + + // given + UUID userId = UUID.randomUUID(); + String newUsername = "test2"; + String newEmail = "test2@test.com"; + String newPassword = "pwd12345"; + + BinaryContent oldProfile = new BinaryContent("old.png", 2L, + "image/png"); + UUID oldProfileId = UUID.randomUUID(); + ReflectionTestUtils.setField(oldProfile, "id", oldProfileId); + + UserUpdateDto updateRequest = new UserUpdateDto(newUsername, newEmail, newPassword); + + User existingUser = User.builder() + .username("test") + .email("test@test.com") + .password("pwd1234") + .profile(oldProfile) + .build(); + + ReflectionTestUtils.setField(existingUser, "id", userId); + + User updatedUser = User.builder() + .username(newUsername) + .email(newEmail) + .password(newPassword) + .build(); + + ReflectionTestUtils.setField(updatedUser, "id", userId); + + UserResponseDto expectedResponse = new UserResponseDto( + userId, newUsername, newEmail, null, null); + + given(userRepository.findById(userId)).willReturn(Optional.of(existingUser)); + given(userRepository.findByUsername(newUsername)).willReturn(Optional.empty()); + given(userRepository.findByEmail(newEmail)).willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willReturn(updatedUser); + given(userMapper.toDto(any(User.class))).willReturn(expectedResponse); + + // when + UserResponseDto result = userService.update(userId, updateRequest, null); + + // then + assertNotNull(result); + assertEquals(expectedResponse, result); + verify(userRepository).save(any(User.class)); + verify(binaryContentRepository).deleteById(oldProfileId); // 기존 프로필 사진 정보 삭제 + } + + @Test + @DisplayName("이미 존재하는 username으로 변경을 시도하면 DuplicateNameException이 발생해야한다.") + void givenDuplicateUsername_whenUpdateUser_thenThrowDuplicateNameException() { + + // given + UUID userId = UUID.randomUUID(); + String existName = "existName"; + + User targetUser = User.builder() + .username("test") + .email("test@test.com") + .password("pwd1234") + .build(); + + User existingUser = User.builder() + .username("existName") + .email("exist@test.com") + .password("pwd12") + .build(); + + ReflectionTestUtils.setField(targetUser, "id", userId); + ReflectionTestUtils.setField(existingUser, "id", UUID.randomUUID()); + + UserUpdateDto updateRequest = new UserUpdateDto(existName, "test@test.com", "pwd1234"); + + given(userRepository.findById(userId)).willReturn(Optional.of(targetUser)); + given(userRepository.findByUsername(existName)).willReturn(Optional.of(existingUser)); + + // when + Throwable thrown = catchThrowable(() -> userService.update(userId, updateRequest, null)); + + // then + assertThat(thrown) + .isInstanceOf(DuplicateNameException.class) + .hasMessageContaining("존재"); + verify(userRepository).findById(userId); + verify(userRepository).findByUsername(existName); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("이미 존재하는 email로 변경을 시도하면 DuplicateEmailException이 발생해야한다.") + void givenDuplicateEmail_whenUpdateUser_thenThrowDuplicateEmailException() { + + // given + UUID userId = UUID.randomUUID(); + String existEmail = "exist@exist.com"; + + User targetUser = User.builder() + .username("test") + .email("test@test.com") + .password("pwd1234") + .build(); + + User existingUser = User.builder() + .username("existName") + .email("exist@exist.com") + .password("pwd12") + .build(); + + ReflectionTestUtils.setField(targetUser, "id", userId); + ReflectionTestUtils.setField(existingUser, "id", UUID.randomUUID()); + + UserUpdateDto updateRequest = new UserUpdateDto("test", existEmail, + "pwd1234"); + + given(userRepository.findById(userId)).willReturn(Optional.of(targetUser)); + given(userRepository.findByEmail(existEmail)).willReturn(Optional.of(existingUser)); + + // when + Throwable thrown = catchThrowable(() -> userService.update(userId, updateRequest, null)); + + // then + assertThat(thrown) + .isInstanceOf(DuplicateEmailException.class) + .hasMessageContaining("존재"); + verify(userRepository).findById(userId); + verify(userRepository).findByEmail(existEmail); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("등록된 사용자 ID로 삭제하면 관련 정보가 모두 삭제되어야 한다.") + void givenRegisteredUserId_whenDeleteUser_thenDeleteUserAndRelatedInfo() { + + // given + UUID userId = UUID.randomUUID(); + BinaryContent profileImage = new BinaryContent("profile.jpg", 3L, + "image/jpeg"); + ReflectionTestUtils.setField(profileImage, "id", UUID.randomUUID()); + + User user = User.builder() + .username("test") + .email("test@test.com") + .password("pwd1234") + .profile(profileImage) + .build(); + + ReflectionTestUtils.setField(user, "id", userId); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // when + userService.deleteById(userId); + + // then + verify(userRepository).deleteById(userId); + verify(userStatusRepository).deleteByUserId(userId); + verify(binaryContentRepository).deleteById(user.getProfile().getId()); + } + + @Test + @DisplayName("등록되지 않은 사용자 ID로 삭제하면 NotFoundUserException이 발생해야 한다.") + void givenNonexistentUserId_whenDeleteUser_thenThrowNotFoundUserException() { + + // given + UUID notExistId = UUID.randomUUID(); + given(userRepository.findById(notExistId)).willReturn(Optional.empty()); + + // when + Throwable thrown = catchThrowable(() -> userService.deleteById(notExistId)); + + // then + assertThat(thrown) + .isInstanceOf(NotFoundUserException.class) + .hasMessageContaining("사용자"); + verify(userRepository).findById(notExistId); + verifyNoMoreInteractions(userStatusRepository, binaryContentRepository); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java new file mode 100644 index 000000000..68d761529 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java @@ -0,0 +1,76 @@ +package com.sprint.mission.discodeit.storage.s3; + +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.core.io.UrlResource; +import org.springframework.http.ResponseEntity; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +@DisplayName("S3BinaryContentStorageTest") +class S3BinaryContentStorageTest { + + private S3BinaryContentStorage binaryContentStorage; + + @BeforeEach + void setUp() { + binaryContentStorage = spy(new S3BinaryContentStorage("abcdefghi", "secretkey5oslr", + "asia", "test-bucket")); + } + + @Test + @DisplayName("S3 업로드 시 S3Client.putObject를 호출하는지 테스트") + void binaryContent_upload_test() { + + // given + UUID id = UUID.randomUUID(); + byte[] bytes = "hello".getBytes(); + + S3Client s3Client = mock(S3Client.class); + willReturn(s3Client).given(binaryContentStorage).getS3Client(); + + // when + binaryContentStorage.put(id, bytes); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(PutObjectRequest.class); + then(s3Client).should().putObject(captor.capture(), any(RequestBody.class)); + assertEquals("test-bucket", captor.getValue().bucket()); + assertEquals(id.toString(), captor.getValue().key()); + } + + @Test + @DisplayName("S3 다운로드 테스트") + void binaryContent_download_test() throws Exception { + + // given + UUID id = UUID.randomUUID(); + String expectedUrl = "https://dummy-url.com/" + id; + willReturn(expectedUrl).given(binaryContentStorage).generatePresignedUrl(any(), + any()); + + BinaryContentResponseDto binaryContentResponseDto = new BinaryContentResponseDto( + id, "test.jpg", 3L, "image/png" + ); + + // when + ResponseEntity result = binaryContentStorage.download(binaryContentResponseDto); + + // then + assertEquals(302, result.getStatusCode().value()); + assertEquals(expectedUrl, result.getHeaders().getLocation().toString()); + } +} \ No newline at end of file