diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..17f33e22 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,22 @@ +--- +name: Bug report +about: 버그 리포트 이슈 템플릿 +title: '' +labels: '' +assignees: '' + +--- + +## 어떤 버그인가요? + +> 어떤 버그인지 간결하게 설명해주세요 + +## 어떤 상황에서 발생한 버그인가요? + +> (가능하면) Given-When-Then 형식으로 서술해주세요 + +## 예상 결과 + +> 예상했던 정상적인 결과가 어떤 것이었는지 설명해주세요 + +## 참고할만한 자료(선택) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..623058b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: 기능 이슈 템플릿 +title: '' +labels: '' +assignees: '' + +--- + +## 어떤 기능인가요? + +> 추가하려는 기능에 대해 간결하게 설명해주세요 + +## 작업 상세 내용 + +- [ ] TODO +- [ ] TODO +- [ ] TODO + +## 참고할만한 자료(선택) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..435f85dc --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## #️⃣연관된 이슈 + +> ex) #이슈번호, #이슈번호 + +## 📝작업 내용 + +> 이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능) + +### 스크린샷 (선택) + +## 💬리뷰 요구사항(선택) + +> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요 +> +> ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요? diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..11d8120b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,67 @@ +name: Deploy to AWS EC2 using Docker + +on: + push: + branches: + - develop + +env: + DOCKER_IMAGE_NAME: ${{ secrets.DEV_DOCKER_IMAGE_NAME }} + EC2_HOST: ${{ secrets.EC2_HOST }} + EC2_SSH_USER: ec2-user + PRIVATE_KEY: ${{ secrets.EC2_SSH_PRIVATE_KEY }} + CONTAINER_NAME: ${{ secrets.DEV_CONTAINER_NAME }} + +jobs: + build-and-push-docker: + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up application-prod.yml + run: echo "${{ secrets.DEV_APPLICATION }}" > ./src/main/resources/application-prod.yml + + - name: Build with Gradle + run: ./gradlew build + + - name: Build the Docker image + run: docker build . --file Dockerfile --tag ${{ env.DOCKER_IMAGE_NAME }}:latest + + - name: Login to Docker Hub using Access Token + run: echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + + - name: Push the Docker image + run: docker push ${{ env.DOCKER_IMAGE_NAME }}:latest + + + deploy-to-ec2: + + needs: build-and-push-docker + runs-on: ubuntu-latest + + steps: + - name: Deploy to EC2 + uses: appleboy/ssh-action@master + with: + host: ${{ env.EC2_HOST }} + username: ${{ env.EC2_SSH_USER }} + key: ${{ env.PRIVATE_KEY }} + script: | + CONTAINER_ID=$(sudo docker ps -q --filter "publish=8089-8089") + + if [ ! -z "$CONTAINER_ID" ]; then + sudo docker stop $CONTAINER_ID + sudo docker rm $CONTAINER_ID + fi + + sudo docker pull ${{ env.DOCKER_IMAGE_NAME }} + sudo docker run --name ${{ env.CONTAINER_NAME }} -d -p 8089:8089 -e TZ=Asia/Seoul ${{ env.DOCKER_IMAGE_NAME }} diff --git a/.github/workflows/deploy_production.yml b/.github/workflows/deploy_production.yml new file mode 100644 index 00000000..998bc243 --- /dev/null +++ b/.github/workflows/deploy_production.yml @@ -0,0 +1,73 @@ +name: Deploy Production Server to AWS EC2 using Docker + +on: + push: + branches: + - main + +env: + DOCKER_IMAGE_NAME: ${{ secrets.PRODUCTION_DOCKER_IMAGE_NAME }} + EC2_HOST: ${{ secrets.EC2_HOST }} + EC2_SSH_USER: ec2-user + PRIVATE_KEY: ${{ secrets.EC2_SSH_PRIVATE_KEY }} + CONTAINER_NAME_BLUE: ${{ secrets.CONTAINER_NAME_BLUE }} + CONTAINER_NAME_GREEN: ${{ secrets.CONTAINER_NAME_GREEN }} + BLUE_PORT: ${{ secrets.BLUE_PORT }} + GREEN_PORT: ${{ secrets.GREEN_PORT }} + +jobs: + build-and-push-docker: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up application.yml + run: echo "${{ secrets.PRODUCTION_APPLICATION }}" > ./src/main/resources/application.yml + + - name: Build with Gradle + run: ./gradlew build -x test + + - name: Build the Docker image + run: docker build . --file Dockerfile --tag ${{ env.DOCKER_IMAGE_NAME }}:latest + + - name: Login to Docker Hub using Access Token + run: echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + + - name: Push the Docker image + run: docker push ${{ env.DOCKER_IMAGE_NAME }}:latest + + deploy-to-ec2: + needs: build-and-push-docker + runs-on: ubuntu-latest + + steps: + - name: Deploy to EC2 + uses: appleboy/ssh-action@master + with: + host: ${{ env.EC2_HOST }} + username: ${{ env.EC2_SSH_USER }} + key: ${{ env.PRIVATE_KEY }} + script: | + if [ $(sudo docker ps -q -f name=${{ env.CONTAINER_NAME_BLUE }}) ]; then + sudo docker pull ${{ env.DOCKER_IMAGE_NAME }} + sudo docker run --name ${{ env.CONTAINER_NAME_GREEN }} -d -p ${{ env.GREEN_PORT }}:${{ env.GREEN_PORT }} -e TZ=Asia/Seoul ${{ env.DOCKER_IMAGE_NAME }} + sleep 30 + sudo docker stop ${{ env.CONTAINER_NAME_BLUE }} + sudo docker rm ${{ env.CONTAINER_NAME_BLUE }} + sudo systemctl reload nginx + else + sudo docker pull ${{ env.DOCKER_IMAGE_NAME }} + sudo docker run --name ${{ env.CONTAINER_NAME_BLUE }} -d -p ${{ env.BLUE_PORT }}:${{ env.BLUE_PORT }} -e TZ=Asia/Seoul ${{ env.DOCKER_IMAGE_NAME }} + sleep 30 + sudo docker stop ${{ env.CONTAINER_NAME_GREEN }} + sudo docker rm ${{ env.CONTAINER_NAME_GREEN }} + sudo systemctl reload nginx + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5b016859 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +src/**/application-prod.yml + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ce3beb34 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM amazoncorretto:17 +# FROM openjdk:17-jdk +ARG JAR_FILE=build/libs/*.jar + +COPY ${JAR_FILE} kkeujeok-backend-0.0.1-SNAPSHOT.jar +# COPY build/libs/*.jar my-project.jar +ENTRYPOINT ["java","-jar","/kkeujeok-backend-0.0.1-SNAPSHOT.jar"] + +RUN ln -snf /usr/share/zoneinfo/Asia/Seoul /etc/localtime \ No newline at end of file diff --git a/README.md b/README.md index 38a2046a..1fea4d75 100644 --- a/README.md +++ b/README.md @@ -1 +1,36 @@ # Kkeujeok_Backend +## 커밋 메시지 컨벤션 + +- Feat(#이슈 번호): 새로운 기능 추가 +- Fix(#이슈 번호): 버그 수정 +- Refactor(#이슈 번호): 코드 리팩토링 (기능 변경 없음) +- Docs(#이슈 번호): 문서 수정 +- Style(#이슈 번호): 코드 포맷팅, 세미콜론 누락 등 (기능 변경 없음) +- Test(#이슈 번호): 테스트 코드 추가 및 수정 +- Chore(#이슈 번호): 빌드 작업, 패키지 매니저 설정 + +## 코드 스타일 + +- 클래스 선언부 아래 필드가 오면 한 칸 띄우고 작성하고 그 이외의 경우에는 붙인다. +- 메서드 길이는 10줄을 넘지 않는다. +- 블록 들여쓰기는 1단계로 제한한다. +- 블록 아래 한 칸 띄우고 작성한다. +- else를 사용하지 않는다. +- stream 사용 시 stream 뒤에 줄바꿈을 한다. +- 필드에 어노테이션이 붙으면 한 칸 씩 띄어쓴다. + +## 메서드 명 컨벤션 + +- 생성: save +- 수정: update +- 삭제: delete +- 조회: find +- 매개변수로 넘어오는 값을 메서드 명에 포함시킨다. + +## 코드 리뷰 컨벤션 + +- 모든 PR은 최소 2명의 리뷰어가 승인한다. + +## 어노테이션 순서 + +- 길이 내림차순 diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..ffda89d2 --- /dev/null +++ b/build.gradle @@ -0,0 +1,87 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.1' + id 'io.spring.dependency-management' version '1.1.5' + id 'org.asciidoctor.jvm.convert' version '4.0.2' +} + +group = 'shop.kkeujeok' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } + asciidoctorExt +} + +repositories { + mavenCentral() + maven { url 'https://jitpack.io' } +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '3.3.2' + implementation 'org.springframework.boot:spring-boot-starter-web-services' + + // logback + implementation 'com.github.napstr:logback-discord-appender:1.0.0' + implementation 'com.github.maricn:logback-slack-appender:1.4.0' + + runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'com.h2database:h2' + + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + //jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // restdocs + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:3.0.1' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:3.0.1' + +} +ext { + snippetsDir = file('build/generated-snippets') +} + +test { + outputs.dir snippetsDir +} + +asciidoctor { + inputs.dir snippetsDir + configurations 'asciidoctorExt' + dependsOn test + baseDirFollowsSourceFile() +} + +bootJar { + dependsOn asciidoctor + from("${asciidoctor.outputDir}") { + into 'static/docs' + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e6441136 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..a4413138 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..b740cf13 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..25da30db --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..50bf52e4 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'kkeujeok-backend' diff --git a/src/docs/asciidoc/block.adoc b/src/docs/asciidoc/block.adoc new file mode 100644 index 00000000..5ce40b65 --- /dev/null +++ b/src/docs/asciidoc/block.adoc @@ -0,0 +1,96 @@ +== 블록 API 문서 + +=== 블록 생성 API + +==== 요청 + +include::{snippets}/block/save/http-request.adoc[] +include::{snippets}/block/save/request-fields.adoc[] + +==== 응답 + +include::{snippets}/block/save/http-response.adoc[] +include::{snippets}/block/save/response-fields.adoc[] + +=== 블록 수정 API + +==== 요청 + +include::{snippets}/block/update/http-request.adoc[] +include::{snippets}/block/update/path-parameters.adoc[] +include::{snippets}/block/update/request-fields.adoc[] + +==== 응답 + +include::{snippets}/block/update/http-response.adoc[] +include::{snippets}/block/update/response-fields.adoc[] + +=== 블록 상태 수정 API + +==== 요청 + +include::{snippets}/block/progress/update/http-request.adoc[] +include::{snippets}/block/progress/update/path-parameters.adoc[] +include::{snippets}/block/progress/update/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/block/progress/update/http-response.adoc[] +include::{snippets}/block/progress/update/response-fields.adoc[] + +=== 블록 상태 수정 실패 API (400 Bad Request) + +==== 요청 + +include::{snippets}/block/progress/update/failure/http-request.adoc[] +include::{snippets}/block/progress/update/failure/path-parameters.adoc[] +include::{snippets}/block/progress/update/failure/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/block/progress/update/failure/http-response.adoc[] + +=== 블록 상태별 전체 조회 + +==== 요청 + +include::{snippets}/block/findByBlockWithProgress/http-request.adoc[] +include::{snippets}/block/findByBlockWithProgress/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/block/findByBlockWithProgress/http-response.adoc[] +include::{snippets}/block/findByBlockWithProgress/response-fields.adoc[] + +=== 블록 상세 조회 + +==== 요청 + +include::{snippets}/block/findById/http-request.adoc[] +include::{snippets}/block/findById/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/block/findById/http-response.adoc[] +include::{snippets}/block/findById/response-fields.adoc[] + +=== 블록 삭제 + +==== 요청 + +include::{snippets}/block/delete/http-request.adoc[] +include::{snippets}/block/delete/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/block/delete/http-response.adoc[] + +=== 블록 상태 변경 + +==== 요청 + +include::{snippets}/block/change/http-request.adoc[] + +==== 응답 + +include::{snippets}/block/change/http-response.adoc[] diff --git a/src/docs/asciidoc/challenge.adoc b/src/docs/asciidoc/challenge.adoc new file mode 100644 index 00000000..17b09457 --- /dev/null +++ b/src/docs/asciidoc/challenge.adoc @@ -0,0 +1,266 @@ += 챌린지 API 문서 + +== 열거형 타입 항목 설명 + +이 문서에서는 챌린지 관련 API에서 사용되는 다양한 열거형 타입의 항목들을 설명합니다. +아래의 각 열거형 타입은 API 요청 및 응답에서 사용될 수 있으며, 각 항목은 Name과 Description으로 구성되어 있습니다. + +- Name: 코드에서 사용되는 열거형 상수의 이름입니다. +- Description: 열거형 상수에 대한 설명입니다.. + +=== category(카테고리) 항목 설명 + +[cols="1,1",options="header"] +|=== +| Name | Description + +| HEALTH_AND_FITNESS +| 건강 및 운동 + +| MENTAL_WELLNESS +| 정신 및 마음관리 + +| PRODUCTIVITY_AND_TIME_MANAGEMENT +| 생산성 및 시간 관리 + +| FINANCE_AND_ASSET_MANAGEMENT +| 재정 및 자산 관리 + +| SELF_DEVELOPMENT +| 자기 개발 + +| LIFE_ORGANIZATION_AND_MANAGEMENT +| 생활 정리 및 관리 + +| SOCIAL_CONNECTIONS +| 사회적 연결 + +| CREATIVITY_AND_ARTS +| 창의력 및 예술 활동 + +| OTHERS +| 기타 +|=== + +=== cycle(주기) 항목 설명 + +[cols="1,1",options="header"] +|=== +| Name | Description + +| DAILY +| 매일 + +| WEEKLY +| 매주 + +| MONTHLY +| 매달 +|=== + +=== cycleDetail(주기 상세 정보) 항목 설명 + +[cols="1,1",options="header"] +|=== +| Name | Description + +| DAILY +| 매일 + +| MON +| 월요일 + +| TUE +| 화요일 + +| WED +| 수요일 + +| THU +| 목요일 + +| FRI +| 금요일 + +| SAT +| 토요일 + +| SUN +| 일요일 + +| FIRST +| 1일 + +| SECOND +| 2일 + +| THIRD +| 3일 + +| FOURTH +| 4일 + +| FIFTH +| 5일 + +| SIXTH +| 6일 + +| SEVENTH +| 7일 + +| EIGHTH +| 8일 + +| NINTH +| 9일 + +| TENTH +| 10일 + +| ELEVENTH +| 11일 + +| TWELFTH +| 12일 + +| THIRTEENTH +| 13일 + +| FOURTEENTH +| 14일 + +| FIFTEENTH +| 15일 + +| SIXTEENTH +| 16일 + +| SEVENTEENTH +| 17일 + +| EIGHTEENTH +| 18일 + +| NINETEENTH +| 19일 + +| TWENTIETH +| 20일 + +| TWENTY_FIRST +| 21일 + +| TWENTY_SECOND +| 22일 + +| TWENTY_THIRD +| 23일 + +| TWENTY_FOURTH +| 24일 + +| TWENTY_FIFTH +| 25일 + +| TWENTY_SIXTH +| 26일 + +| TWENTY_SEVENTH +| 27일 + +| TWENTY_EIGHTH +| 28일 + +| TWENTY_NINTH +| 29일 + +| THIRTIETH +| 30일 + +| THIRTY_FIRST +| 31일 +|=== + +== 챌린지 생성 API + +=== 요청 + +include::{snippets}/challenge/save/http-request.adoc[] +include::{snippets}/challenge/save/request-fields.adoc[] + +=== 응답 + +include::{snippets}/challenge/save/http-response.adoc[] +include::{snippets}/challenge/save/response-fields.adoc[] + +== 챌린지 수정 API + +=== 요청 + +include::{snippets}/challenge/update/http-request.adoc[] +include::{snippets}/challenge/update/path-parameters.adoc[] +include::{snippets}/challenge/update/request-fields.adoc[] + +=== 응답 + +include::{snippets}/challenge/update/http-response.adoc[] +include::{snippets}/challenge/update/response-fields.adoc[] + +== 챌린지 전체 조회 API + +=== 요청 + +include::{snippets}/challenge/findAll/http-request.adoc[] +include::{snippets}/challenge/findAll/response-fields.adoc[] + +=== 응답 + +include::{snippets}/challenge/findAll/http-response.adoc[] +include::{snippets}/challenge/findAll/response-fields.adoc[] + +== 챌린지 검색 API + +=== 요청 + +include::{snippets}/challenge/search/http-request.adoc[] +include::{snippets}/challenge/search/response-fields.adoc[] + +=== 응답 + +include::{snippets}/challenge/search/http-response.adoc[] +include::{snippets}/challenge/search/response-fields.adoc[] + +== 챌린지 상세 조회 API + +=== 요청 + +include::{snippets}/challenge/findById/http-request.adoc[] +include::{snippets}/challenge/findById/path-parameters.adoc + +=== 응답 + +include::{snippets}/challenge/findById/http-response.adoc[] +include::{snippets}/challenge/findById/response-fields.adoc[] + +== 챌린지 삭제 API + +=== 요청 + +include::{snippets}/challenge/delete/http-request.adoc[] +include::{snippets}/challenge/delete/path-parameters.adoc[] + +=== 응답 + +include::{snippets}/challenge/delete/http-response.adoc[] + +== 챌린지 참여 API + +include::{snippets}/challenge/addChallengeToPersonalDashboard/http-request.adoc[] +include::{snippets}/challenge/addChallengeToPersonalDashboard/path-parameters.adoc[] + +=== 응답 + +include::{snippets}/challenge/addChallengeToPersonalDashboard/http-response.adoc[] + + diff --git a/src/docs/asciidoc/document.adoc b/src/docs/asciidoc/document.adoc new file mode 100644 index 00000000..9261b878 --- /dev/null +++ b/src/docs/asciidoc/document.adoc @@ -0,0 +1,49 @@ +== 문서 API 문서 + +=== 문서 생성 API + +==== 요청 + +include::{snippets}/document/save/http-request.adoc[] +include::{snippets}/document/save/request-fields.adoc[] + +==== 응답 + +include::{snippets}/document/save/http-response.adoc[] +include::{snippets}/document/save/response-fields.adoc[] + +=== 문서 수정 API + +==== 요청 + +include::{snippets}/document/update/http-request.adoc[] +include::{snippets}/document/update/path-parameters.adoc[] +include::{snippets}/document/update/request-fields.adoc[] + +==== 응답 + +include::{snippets}/document/update/http-response.adoc[] +include::{snippets}/document/update/response-fields.adoc[] + +=== 문서 리스트 조회 API + +==== 요청 + +include::{snippets}/document/findForDocumentByTeamDashboardId/http-request.adoc[] +include::{snippets}/document/findForDocumentByTeamDashboardId/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/document/findForDocumentByTeamDashboardId/http-response.adoc[] +include::{snippets}/document/findForDocumentByTeamDashboardId/response-fields.adoc[] + +=== 문서 삭제 API + +==== 요청 + +include::{snippets}/document/delete/http-request.adoc[] +include::{snippets}/document/delete/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/document/delete/http-response.adoc[] diff --git a/src/docs/asciidoc/file.adoc b/src/docs/asciidoc/file.adoc new file mode 100644 index 00000000..bb83daf7 --- /dev/null +++ b/src/docs/asciidoc/file.adoc @@ -0,0 +1,61 @@ +== 파일 API 문서 + +=== 파일 저장 API + +==== 요청 + +include::{snippets}/file/save/http-request.adoc[] +include::{snippets}/file/save/request-fields.adoc[] + +==== 응답 + +include::{snippets}/file/save/http-response.adoc[] +include::{snippets}/file/save/response-fields.adoc[] + +=== 파일 수정 API + +==== 요청 + +include::{snippets}/file/update/http-request.adoc[] +include::{snippets}/file/update/path-parameters.adoc[] +include::{snippets}/file/update/request-fields.adoc[] + +==== 응답 + +include::{snippets}/file/update/http-response.adoc[] +include::{snippets}/file/update/response-fields.adoc[] + +=== 파일 리스트 조회 API + +==== 요청 + +include::{snippets}/file/findForFile/http-request.adoc[] +include::{snippets}/file/findForFile/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/file/findForFile/http-response.adoc[] +include::{snippets}/file/findForFile/response-fields.adoc[] + +=== 파일 상세 조회 API + +==== 요청 + +include::{snippets}/file/findById/http-request.adoc[] +include::{snippets}/file/findById/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/file/findById/http-response.adoc[] +include::{snippets}/file/findById/response-fields.adoc[] + +=== 파일 삭제 API + +==== 요청 + +include::{snippets}/file/delete/http-request.adoc[] +include::{snippets}/file/delete/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/file/delete/http-response.adoc[] diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 00000000..f9dd6da4 --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,12 @@ += 끄적끄적 API 문서 +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 + +include::block.adoc[] +include::challenge.adoc[] + +include::personalDashboard.adoc[] +include::notification.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/login.adoc b/src/docs/asciidoc/login.adoc new file mode 100644 index 00000000..17c23586 --- /dev/null +++ b/src/docs/asciidoc/login.adoc @@ -0,0 +1,25 @@ +== 로그인 API 문서 + +=== 로그인 API + +==== 요청 + +include::{snippets}/auth/login/http-request.adoc[] +include::{snippets}/auth/login/request-fields.adoc[] + +==== 응답 + +include::{snippets}/auth/login/http-response.adoc[] +include::{snippets}/auth/login/response-fields.adoc[] + +=== 리프레쉬 토큰으로 액세스 토큰 발급 API + +==== 요청 + +include::{snippets}/auth/getNewAccessToken/http-request.adoc[] +include::{snippets}/auth/getNewAccessToken/request-fields.adoc[] + +==== 응답 + +include::{snippets}/auth/getNewAccessToken/http-response.adoc[] +include::{snippets}/auth/getNewAccessToken/response-fields.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/member.adoc b/src/docs/asciidoc/member.adoc new file mode 100644 index 00000000..e485e244 --- /dev/null +++ b/src/docs/asciidoc/member.adoc @@ -0,0 +1,25 @@ +== 멤버 API 문서 + +=== 내 프로필 정보 조회 API + +==== 요청 + +include::{snippets}/member/mypage/http-request.adoc[] +include::{snippets}/member/mypage/request-headers.adoc[] + +==== 응답 + +include::{snippets}/member/mypage/http-response.adoc[] +include::{snippets}/member/mypage/response-fields.adoc[] + +=== 팀 대시보드와 챌린지 정보 조회 API + +==== 요청 + +include::{snippets}/member/team-challenges/http-request.adoc[] +include::{snippets}/member/team-challenges/request-headers.adoc[] + +==== 응답 + +include::{snippets}/member/team-challenges/http-response.adoc[] +include::{snippets}/member/team-challenges/response-fields.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/notification.adoc b/src/docs/asciidoc/notification.adoc new file mode 100644 index 00000000..09a23c3d --- /dev/null +++ b/src/docs/asciidoc/notification.adoc @@ -0,0 +1,35 @@ +== 알림 API 문서 + +=== 알림 등록 API + +==== 요청 + +include::{snippets}/notification/stream/http-request.adoc[] +include::{snippets}/notification/stream/request-headers.adoc[] + +==== 응답 + +include::{snippets}/notification/stream/http-response.adoc[] + +=== 알림 전체 리스트 조회 + +==== 요청 + +include::{snippets}/notification/findAll/http-request.adoc[] +include::{snippets}/notification/findAll/request-headers.adoc[] + +==== 응답 + +include::{snippets}/notification/findAll/http-response.adoc[] +include::{snippets}/notification/findAll/response-fields.adoc[] + +=== 알림 상세 조회 + +==== 요청 + +include::{snippets}/notification/findById/http-request.adoc[] + +==== 응답 + +include::{snippets}/notification/findById/http-response.adoc[] +include::{snippets}/notification/findById/response-fields.adoc[] diff --git a/src/docs/asciidoc/personalDashboard.adoc b/src/docs/asciidoc/personalDashboard.adoc new file mode 100644 index 00000000..4b6a4d7c --- /dev/null +++ b/src/docs/asciidoc/personalDashboard.adoc @@ -0,0 +1,77 @@ +== 개인 대시보드 API 문서 + +=== 개인 대시보드 생성 API + +==== 요청 + +include::{snippets}/dashboard/personal/save/http-request.adoc[] +include::{snippets}/dashboard/personal/save/request-headers.adoc[] +include::{snippets}/dashboard/personal/save/request-fields.adoc[] + +==== 응답 + +include::{snippets}/dashboard/personal/save/http-response.adoc[] +include::{snippets}/dashboard/personal/save/response-fields.adoc[] + +=== 개인 대시보드 수정 API + +==== 요청 + +include::{snippets}/dashboard/personal/update/http-request.adoc[] +include::{snippets}/dashboard/personal/update/request-headers.adoc[] +include::{snippets}/dashboard/personal/update/path-parameters.adoc[] +include::{snippets}/dashboard/personal/update/request-fields.adoc[] + +==== 응답 + +include::{snippets}/dashboard/personal/update/http-response.adoc[] +include::{snippets}/dashboard/personal/update/response-fields.adoc[] + +=== 개인 대시보드 전체 조회 + +==== 요청 + +include::{snippets}/dashboard/personal/findForPersonalDashboard/http-request.adoc[] +include::{snippets}/dashboard/personal/findForPersonalDashboard/request-headers.adoc[] + +==== 응답 + +include::{snippets}/dashboard/personal/findForPersonalDashboard/http-response.adoc[] +include::{snippets}/dashboard/personal/findForPersonalDashboard/response-fields.adoc[] + +=== 개인 대시보드 상세 조회 + +==== 요청 + +include::{snippets}/dashboard/personal/findById/http-request.adoc[] +include::{snippets}/dashboard/personal/findById/request-headers.adoc[] +include::{snippets}/dashboard/personal/findById/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/personal/findById/http-response.adoc[] +include::{snippets}/dashboard/personal/findById/response-fields.adoc[] + +=== 사용자의 개인 대시보드 카테고리 조회 + +==== 요청 + +include::{snippets}/dashboard/personal/categories/http-request.adoc[] +include::{snippets}/dashboard/personal/categories/request-headers.adoc[] + +==== 응답 + +include::{snippets}/dashboard/personal/categories/http-response.adoc[] +include::{snippets}/dashboard/personal/categories/response-fields.adoc[] + +=== 개인 대시보드 삭제 API + +==== 요청 + +include::{snippets}/dashboard/personal/delete/http-request.adoc[] +include::{snippets}/dashboard/personal/delete/request-headers.adoc[] +include::{snippets}/dashboard/personal/delete/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/personal/delete/http-response.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/teamDashboard.adoc b/src/docs/asciidoc/teamDashboard.adoc new file mode 100644 index 00000000..278c275d --- /dev/null +++ b/src/docs/asciidoc/teamDashboard.adoc @@ -0,0 +1,93 @@ +== 팀 대시보드 API 문서 + +=== 팀 대시보드 생성 API + +==== 요청 + +include::{snippets}/dashboard/team/save/http-request.adoc[] +include::{snippets}/dashboard/team/save/request-fields.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/save/http-response.adoc[] +include::{snippets}/dashboard/team/save/response-fields.adoc[] + +=== 팀 대시보드 수정 API + +==== 요청 + +include::{snippets}/dashboard/team/update/http-request.adoc[] +include::{snippets}/dashboard/team/update/path-parameters.adoc[] +include::{snippets}/dashboard/team/update/request-fields.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/update/http-response.adoc[] +include::{snippets}/dashboard/team/update/response-fields.adoc[] + +=== 팀 대시보드 전체 조회 + +==== 요청 + +include::{snippets}/dashboard/team/findForTeamDashboard/http-request.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/findForTeamDashboard/http-response.adoc[] +include::{snippets}/dashboard/team/findForTeamDashboard/response-fields.adoc[] + +=== 팀 대시보드 상세 조회 + +==== 요청 + +include::{snippets}/dashboard/team/findById/http-request.adoc[] +include::{snippets}/dashboard/team/findById/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/findById/http-response.adoc[] +include::{snippets}/dashboard/team/findById/response-fields.adoc[] + +=== 팀 대시보드 삭제 + +==== 요청 + +include::{snippets}/dashboard/team/delete/http-request.adoc[] +include::{snippets}/dashboard/team/delete/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/delete/http-response.adoc[] + +=== 팀 대시보드 초대 멤버 리스트 조회 + +==== 요청 + +include::{snippets}/dashboard/team/search/http-request.adoc[] +include::{snippets}/dashboard/team/search/query-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/search/http-response.adoc[] + +=== 팀 대시보드 참여 초대 수락 + +==== 요청 + +include::{snippets}/dashboard/team/join/http-request.adoc[] +include::{snippets}/dashboard/team/join/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/join/http-response.adoc[] + +=== 팀 대시보드 탈퇴 + +==== 요청 + +include::{snippets}/dashboard/team/leave/http-request.adoc[] +include::{snippets}/dashboard/team/leave/path-parameters.adoc[] + +==== 응답 + +include::{snippets}/dashboard/team/leave/http-response.adoc[] diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/KkeujeokBackendApplication.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/KkeujeokBackendApplication.java new file mode 100644 index 00000000..7a78a4f8 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/KkeujeokBackendApplication.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class KkeujeokBackendApplication { + + public static void main(String[] args) { + SpringApplication.run(KkeujeokBackendApplication.class, args); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthController.java new file mode 100644 index 00000000..4609466e --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthController.java @@ -0,0 +1,64 @@ +package shop.kkeujeok.kkeujeokbackend.auth.api; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.RefreshTokenReqDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.MemberLoginResDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; +import shop.kkeujeok.kkeujeokbackend.auth.application.AuthMemberService; +import shop.kkeujeok.kkeujeokbackend.auth.application.AuthService; +import shop.kkeujeok.kkeujeokbackend.auth.application.AuthServiceFactory; +import shop.kkeujeok.kkeujeokbackend.auth.application.TokenService; +import shop.kkeujeok.kkeujeokbackend.global.jwt.api.dto.TokenDto; +import shop.kkeujeok.kkeujeokbackend.global.oauth.GoogleAuthService; +import shop.kkeujeok.kkeujeokbackend.global.oauth.KakaoAuthService; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +@Slf4j +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class AuthController { + + private final AuthServiceFactory authServiceFactory; + private final AuthMemberService memberService; + private final TokenService tokenService; + + @GetMapping("oauth2/callback/google") + public JsonNode googleCallback(@RequestParam(name = "code") String code) { + AuthService googleAuthService = authServiceFactory.getAuthService("google"); + return googleAuthService.getIdToken(code); + } + + @GetMapping("oauth2/callback/kakao") + public JsonNode kakaoCallback(@RequestParam(name = "code") String code) { + AuthService kakaoAuthService = authServiceFactory.getAuthService("kakao"); + return kakaoAuthService.getIdToken(code); + } + + @PostMapping("/{provider}/token") + public RspTemplate generateAccessAndRefreshToken( + @PathVariable(name = "provider") String provider, + @RequestBody TokenReqDto tokenReqDto) { + AuthService authService = authServiceFactory.getAuthService(provider); + UserInfo userInfo = authService.getUserInfo(tokenReqDto.authCode()); + + MemberLoginResDto getMemberDto = memberService.saveUserInfo(userInfo, + SocialType.valueOf(provider.toUpperCase())); + TokenDto getToken = tokenService.getToken(getMemberDto); + + return new RspTemplate<>(HttpStatus.OK, "토큰 발급", getToken); + } + + @PostMapping("/token/access") + public RspTemplate generateAccessToken(@RequestBody RefreshTokenReqDto refreshTokenReqDto) { + TokenDto getToken = tokenService.generateAccessToken(refreshTokenReqDto); + + return new RspTemplate<>(HttpStatus.OK, "액세스 토큰 발급", getToken); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/request/RefreshTokenReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/request/RefreshTokenReqDto.java new file mode 100644 index 00000000..9f67ad58 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/request/RefreshTokenReqDto.java @@ -0,0 +1,6 @@ +package shop.kkeujeok.kkeujeokbackend.auth.api.dto.request; + +public record RefreshTokenReqDto( + String refreshToken +){ +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/request/TokenReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/request/TokenReqDto.java new file mode 100644 index 00000000..42a86934 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/request/TokenReqDto.java @@ -0,0 +1,6 @@ +package shop.kkeujeok.kkeujeokbackend.auth.api.dto.request; + +public record TokenReqDto( + String authCode +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/response/AccessAndRefreshTokenResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/response/AccessAndRefreshTokenResDto.java new file mode 100644 index 00000000..b6e375b8 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/response/AccessAndRefreshTokenResDto.java @@ -0,0 +1,10 @@ +package shop.kkeujeok.kkeujeokbackend.auth.api.dto.response; + +import lombok.Builder; + +@Builder +public record AccessAndRefreshTokenResDto( + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/response/MemberLoginResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/response/MemberLoginResDto.java new file mode 100644 index 00000000..f8073197 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/response/MemberLoginResDto.java @@ -0,0 +1,16 @@ +package shop.kkeujeok.kkeujeokbackend.auth.api.dto.response; + + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Builder +public record MemberLoginResDto( + Member findMember +) { + public static MemberLoginResDto from(Member member) { + return MemberLoginResDto.builder() + .findMember(member) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/response/UserInfo.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/response/UserInfo.java new file mode 100644 index 00000000..edd7b097 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/api/dto/response/UserInfo.java @@ -0,0 +1,9 @@ +package shop.kkeujeok.kkeujeokbackend.auth.api.dto.response; + +public record UserInfo( + String email, + String name, + String picture, + String nickname +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberService.java new file mode 100644 index 00000000..44399963 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberService.java @@ -0,0 +1,91 @@ +package shop.kkeujeok.kkeujeokbackend.auth.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.MemberLoginResDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; +import shop.kkeujeok.kkeujeokbackend.auth.exception.EmailNotFoundException; +import shop.kkeujeok.kkeujeokbackend.auth.exception.ExistsMemberEmailException; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.Role; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.nickname.application.NicknameService; +import shop.kkeujeok.kkeujeokbackend.member.tag.application.TagService; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthMemberService { + + private final MemberRepository memberRepository; + private final NicknameService nicknameService; + private final TagService tagService; + + @Transactional + public MemberLoginResDto saveUserInfo(UserInfo userInfo, SocialType provider) { + validateNotFoundEmail(userInfo.email()); + + Member member = getExistingMemberOrCreateNew(userInfo, provider); + + validateSocialType(member, provider); + + return MemberLoginResDto.from(member); + } + + private void validateNotFoundEmail(String email) { + if (email == null) { + throw new EmailNotFoundException(); + } + } + + private Member getExistingMemberOrCreateNew(UserInfo userInfo, SocialType provider) { + return memberRepository.findByEmail(userInfo.email()).orElseGet(() -> createMember(userInfo, provider)); + } + + private Member createMember(UserInfo userInfo, SocialType provider) { + String userPicture = getUserPicture(userInfo.picture()); + String name = unionName(userInfo.name(), userInfo.nickname()); + String nickname = nicknameService.getRandomNickname(); + String tag = tagService.getRandomTag(nickname); + + return memberRepository.save( + Member.builder() + .status(Status.ACTIVE) + .email(userInfo.email()) + .name(name) + .picture(userPicture) + .socialType(provider) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname(nickname) + .introduction("자기 소개를 입력해 주세요.") + .tag(tag) + .build() + ); + } + + private String unionName(String name, String nickname) { + return nickname != null ? nickname : name; + } + + private String getUserPicture(String picture) { + return Optional.ofNullable(picture) + .map(this::convertToHighRes) + .orElseThrow(); + } + + private String convertToHighRes(String url){ + return url.replace("s96-c", "s2048-c"); + } + + private void validateSocialType(Member member, SocialType provider) { + if (!provider.equals(member.getSocialType())) { + throw new ExistsMemberEmailException(); + } + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthService.java new file mode 100644 index 00000000..6994a548 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthService.java @@ -0,0 +1,12 @@ +package shop.kkeujeok.kkeujeokbackend.auth.application; + +import com.fasterxml.jackson.databind.JsonNode; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; + +public interface AuthService { + UserInfo getUserInfo(String authCode); + + String getProvider(); + + JsonNode getIdToken(String code); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactory.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactory.java new file mode 100644 index 00000000..97cbc9e8 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactory.java @@ -0,0 +1,26 @@ +package shop.kkeujeok.kkeujeokbackend.auth.application; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class AuthServiceFactory { + + private final Map authServiceMap; + + @Autowired + public AuthServiceFactory(List authServiceList) { + authServiceMap = new HashMap<>(); + for (AuthService authService : authServiceList) { + authServiceMap.put(authService.getProvider(), authService); + } + } + + public AuthService getAuthService(String provider) { + return authServiceMap.get(provider); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenService.java new file mode 100644 index 00000000..12974902 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenService.java @@ -0,0 +1,65 @@ +package shop.kkeujeok.kkeujeokbackend.auth.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.RefreshTokenReqDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.MemberLoginResDto; +import shop.kkeujeok.kkeujeokbackend.auth.exception.InvalidTokenException; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; +import shop.kkeujeok.kkeujeokbackend.global.jwt.api.dto.TokenDto; +import shop.kkeujeok.kkeujeokbackend.global.jwt.domain.Token; +import shop.kkeujeok.kkeujeokbackend.global.jwt.domain.repository.TokenRepository; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TokenService { + + private final TokenProvider tokenProvider; + private final TokenRepository tokenRepository; + private final MemberRepository memberRepository; + + @Transactional + public TokenDto getToken(MemberLoginResDto memberLoginResDto) { + TokenDto tokenDto = tokenProvider.generateToken(memberLoginResDto.findMember().getEmail()); + + tokenSaveAndUpdate(memberLoginResDto, tokenDto); + + return tokenDto; + } + + private void tokenSaveAndUpdate(MemberLoginResDto memberLoginResDto, TokenDto tokenDto) { + if (!tokenRepository.existsByMember(memberLoginResDto.findMember())) { + tokenRepository.save(Token.builder() + .member(memberLoginResDto.findMember()) + .refreshToken(tokenDto.refreshToken()) + .build()); + } + + refreshTokenUpdate(memberLoginResDto, tokenDto); + } + + private void refreshTokenUpdate(MemberLoginResDto memberLoginResDto, TokenDto tokenDto) { + Token token = tokenRepository.findByMember(memberLoginResDto.findMember()).orElseThrow(); + token.refreshTokenUpdate(tokenDto.refreshToken()); + } + + @Transactional + public TokenDto generateAccessToken(RefreshTokenReqDto refreshTokenReqDto) { + if (isInvalidRefreshToken(refreshTokenReqDto.refreshToken())) { + throw new InvalidTokenException(); + } + + Token token = tokenRepository.findByRefreshToken(refreshTokenReqDto.refreshToken()).orElseThrow(); + Member member = memberRepository.findById(token.getMember().getId()).orElseThrow(); + + return tokenProvider.generateAccessTokenByRefreshToken(member.getEmail(), token.getRefreshToken()); + } + + private boolean isInvalidRefreshToken(String refreshToken) { + return !tokenRepository.existsByRefreshToken(refreshToken) || !tokenProvider.validateToken(refreshToken); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/EmailNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/EmailNotFoundException.java new file mode 100644 index 00000000..de9757d1 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/EmailNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.auth.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class EmailNotFoundException extends NotFoundGroupException { + public EmailNotFoundException(String message) { + super(message); + } + + public EmailNotFoundException() { + this("존재하지 않는 이메일 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/ExistsMemberEmailException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/ExistsMemberEmailException.java new file mode 100644 index 00000000..a8041770 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/ExistsMemberEmailException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.auth.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.InvalidGroupException; + +public class ExistsMemberEmailException extends InvalidGroupException { + public ExistsMemberEmailException(String message) { + super(message); + } + + public ExistsMemberEmailException() { + this("이미 가입한 계정이 있는 이메일 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/InvalidTokenException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/InvalidTokenException.java new file mode 100644 index 00000000..b66046e7 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/auth/exception/InvalidTokenException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.auth.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.AuthGroupException; + +public class InvalidTokenException extends AuthGroupException { + public InvalidTokenException(String message) { + super(message); + } + + public InvalidTokenException() { + this("토큰이 유효하지 않습니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockController.java new file mode 100644 index 00000000..b40aec19 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockController.java @@ -0,0 +1,82 @@ +package shop.kkeujeok.kkeujeokbackend.block.api; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSequenceUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockListResDto; +import shop.kkeujeok.kkeujeokbackend.block.application.BlockService; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/blocks") +public class BlockController { + + private final BlockService blockService; + + @PostMapping("/") + public RspTemplate save(@CurrentUserEmail String email, + @RequestBody BlockSaveReqDto blockSaveReqDto) { + return new RspTemplate<>(HttpStatus.CREATED, "블럭 생성", blockService.save(email, blockSaveReqDto)); + } + + @PatchMapping("/{blockId}") + public RspTemplate update(@CurrentUserEmail String email, + @PathVariable(name = "blockId") Long blockId, + @RequestBody BlockUpdateReqDto blockUpdateReqDto) { + return new RspTemplate<>(HttpStatus.OK, "블록 수정", blockService.update(email, blockId, blockUpdateReqDto)); + } + + @PatchMapping("/{blockId}/progress") + public RspTemplate progressUpdate(@CurrentUserEmail String email, + @PathVariable(name = "blockId") Long blockId, + @RequestParam(name = "progress") String progress) { + return new RspTemplate<>(HttpStatus.OK, "블록 상태 수정", blockService.progressUpdate(email, blockId, progress)); + } + + @GetMapping("") + public RspTemplate findForBlockByProgress(@CurrentUserEmail String email, + @RequestParam(name = "dashboardId") Long dashboardId, + @RequestParam(name = "progress") String progress, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size) { + return new RspTemplate<>(HttpStatus.OK, + "블록 상태별 전체 조회", + blockService.findForBlockByProgress(email, dashboardId, progress, PageRequest.of(page, size))); + } + + @DeleteMapping("/{blockId}") + public RspTemplate delete(@CurrentUserEmail String email, + @PathVariable(name = "blockId") Long blockId) { + blockService.delete(email, blockId); + return new RspTemplate<>(HttpStatus.OK, "블록 삭제, 복구"); + } + + @GetMapping("/{blockId}") + public RspTemplate findById(@CurrentUserEmail String email, + @PathVariable(name = "blockId") Long blockId) { + return new RspTemplate<>(HttpStatus.OK, "블록 상세보기", blockService.findById(email, blockId)); + } + + @PatchMapping("/change") + public RspTemplate changeBlocksSequence(@CurrentUserEmail String email, + @RequestBody BlockSequenceUpdateReqDto blockSequenceUpdateReqDto) { + blockService.changeBlocksSequence(email, blockSequenceUpdateReqDto); + return new RspTemplate<>(HttpStatus.OK, "블록 순서 변경"); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockSaveReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockSaveReqDto.java new file mode 100644 index 00000000..480480a6 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockSaveReqDto.java @@ -0,0 +1,30 @@ +package shop.kkeujeok.kkeujeokbackend.block.api.dto.request; + +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public record BlockSaveReqDto( + Long dashboardId, + String title, + String contents, + Progress progress, + String startDate, + String deadLine +) { + public Block toEntity(Member member, Dashboard dashboard, int lastSequence) { + return Block.builder() + .title(title) + .contents(contents) + .progress(progress) + .type(Type.BASIC) + .startDate(startDate) + .deadLine(deadLine) + .sequence(lastSequence + 1) + .member(member) + .dashboard(dashboard) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockSequenceUpdateReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockSequenceUpdateReqDto.java new file mode 100644 index 00000000..fe291c90 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockSequenceUpdateReqDto.java @@ -0,0 +1,10 @@ +package shop.kkeujeok.kkeujeokbackend.block.api.dto.request; + +import java.util.List; + +public record BlockSequenceUpdateReqDto( + List notStartedList, + List inProgressList, + List completedList +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockUpdateReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockUpdateReqDto.java new file mode 100644 index 00000000..8dc2df74 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/request/BlockUpdateReqDto.java @@ -0,0 +1,9 @@ +package shop.kkeujeok.kkeujeokbackend.block.api.dto.request; + +public record BlockUpdateReqDto( + String title, + String contents, + String startDate, + String deadLine +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/response/BlockInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/response/BlockInfoResDto.java new file mode 100644 index 00000000..627d691a --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/response/BlockInfoResDto.java @@ -0,0 +1,47 @@ +package shop.kkeujeok.kkeujeokbackend.block.api.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; + +@Builder +public record BlockInfoResDto( + Long blockId, + String title, + String contents, + Progress progress, + Type type, + String dType, + String startDate, + String deadLine, + String nickname, + int dDay +) { + public static BlockInfoResDto from(Block block) { + return BlockInfoResDto.builder() + .blockId(block.getId()) + .title(block.getTitle()) + .contents(block.getContents()) + .progress(block.getProgress()) + .type(block.getType()) + .dType(block.getDashboard().getDType()) + .startDate(block.getStartDate()) + .deadLine(block.getDeadLine()) + .nickname(block.getMember().getNickname()) + .dDay(calculateDDay(block.getDeadLine())) + .build(); + } + + private static int calculateDDay(String deadlineStr) { + LocalDateTime deadline = LocalDateTime.parse(deadlineStr, DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm")); + LocalDate today = LocalDate.now(); + LocalDate deadlineDate = deadline.toLocalDate(); + return Math.toIntExact(ChronoUnit.DAYS.between(today, deadlineDate)); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/response/BlockListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/response/BlockListResDto.java new file mode 100644 index 00000000..615c3ef7 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/api/dto/response/BlockListResDto.java @@ -0,0 +1,18 @@ +package shop.kkeujeok.kkeujeokbackend.block.api.dto.response; + +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +@Builder +public record BlockListResDto( + List blockListResDto, + PageInfoResDto pageInfoResDto +) { + public static BlockListResDto from(List blocks, PageInfoResDto pageInfoResDto) { + return BlockListResDto.builder() + .blockListResDto(blocks) + .pageInfoResDto(pageInfoResDto) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockService.java new file mode 100644 index 00000000..58bb9a75 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockService.java @@ -0,0 +1,141 @@ +package shop.kkeujeok.kkeujeokbackend.block.application; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSequenceUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockListResDto; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; +import shop.kkeujeok.kkeujeokbackend.block.exception.BlockNotFoundException; +import shop.kkeujeok.kkeujeokbackend.block.exception.InvalidProgressException; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository.DashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.exception.DashboardNotFoundException; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.exception.MemberNotFoundException; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BlockService { + + private final MemberRepository memberRepository; + private final BlockRepository blockRepository; + private final DashboardRepository dashboardRepository; + + // 블록 생성 + @Transactional + public BlockInfoResDto save(String email, BlockSaveReqDto blockSaveReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Dashboard dashboard = dashboardRepository.findById(blockSaveReqDto.dashboardId()) + .orElseThrow(DashboardNotFoundException::new); + + int lastSequence = blockRepository.findLastSequenceByProgress( + member, + dashboard.getId(), + blockSaveReqDto.progress()); + + Block block = blockRepository.save(blockSaveReqDto.toEntity(member, dashboard, lastSequence)); + + return BlockInfoResDto.from(block); + } + + // 블록 수정 (자동 수정 예정) + @Transactional + public BlockInfoResDto update(String email, Long blockId, BlockUpdateReqDto blockUpdateReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Block block = blockRepository.findById(blockId).orElseThrow(BlockNotFoundException::new); + + block.update(blockUpdateReqDto.title(), + blockUpdateReqDto.contents(), + blockUpdateReqDto.startDate(), + blockUpdateReqDto.deadLine()); + + return BlockInfoResDto.from(block); + } + + // 블록 상태 업데이트 (Progress) + @Transactional + public BlockInfoResDto progressUpdate(String email, Long blockId, String progressString) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Block block = blockRepository.findById(blockId).orElseThrow(BlockNotFoundException::new); + + Progress progress = parseProgress(progressString); + + block.progressUpdate(progress); + + return BlockInfoResDto.from(block); + } + + // 블록 리스트 + public BlockListResDto findForBlockByProgress(String email, Long dashboardId, String progress, Pageable pageable) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Page blocks = blockRepository.findByBlockWithProgress(dashboardId, parseProgress(progress), pageable); + + List blockInfoResDtoList = blocks.stream() + .map(BlockInfoResDto::from) + .toList(); + + return BlockListResDto.from(blockInfoResDtoList, PageInfoResDto.from(blocks)); + } + + // 블록 상세보기 + public BlockInfoResDto findById(String email, Long blockId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Block block = blockRepository.findById(blockId).orElseThrow(BlockNotFoundException::new); + + return BlockInfoResDto.from(block); + } + + // 블록 삭제 유무 업데이트 (논리 삭제) + @Transactional + public void delete(String email, Long blockId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Block block = blockRepository.findById(blockId).orElseThrow(BlockNotFoundException::new); + + block.statusUpdate(); + } + + // 블록 순번 변경 + @Transactional + public void changeBlocksSequence(String email, BlockSequenceUpdateReqDto blockSequenceUpdateReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + + updateBlockSequence(blockSequenceUpdateReqDto.notStartedList()); + updateBlockSequence(blockSequenceUpdateReqDto.inProgressList()); + updateBlockSequence(blockSequenceUpdateReqDto.completedList()); + } + + private void updateBlockSequence(List blockIds) { + int sequence = blockIds.size(); + + for (Long blockId : blockIds) { + Block block = blockRepository.findById(blockId).orElseThrow(BlockNotFoundException::new); + + if (block.getSequence() != sequence) { + block.sequenceUpdate(sequence); + } + + sequence--; + } + } + + private Progress parseProgress(String progressString) { + try { + return Progress.valueOf(progressString); + } catch (IllegalArgumentException e) { + throw new InvalidProgressException(); + } + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Block.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Block.java new file mode 100644 index 00000000..36c49c4e --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Block.java @@ -0,0 +1,109 @@ +package shop.kkeujeok.kkeujeokbackend.block.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Block extends BaseEntity { + + @Enumerated(value = EnumType.STRING) + private Status status; + + private String title; + + @Column(columnDefinition = "TEXT") + private String contents; + + @Enumerated(value = EnumType.STRING) + private Progress progress; + + @Enumerated(value = EnumType.STRING) + private Type type; + + private String startDate; + + private String deadLine; + + private int sequence; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "dashboard_id") + private Dashboard dashboard; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "challenge_id") + private Challenge challenge; + + @Builder + private Block(String title, String contents, Progress progress, Type type, Member member, String startDate, + String deadLine, int sequence, Dashboard dashboard, Challenge challenge) { + this.status = Status.ACTIVE; + this.title = title; + this.contents = contents; + this.progress = progress; + this.type = type; + this.startDate = startDate; + this.deadLine = deadLine; + this.sequence = sequence; + this.member = member; + this.dashboard = dashboard; + this.challenge = challenge; + } + + public void update(String updateTitle, String updateContents, String updateStartDate, String updateDeadLine) { + if (isUpdateRequired(updateTitle, updateContents, updateStartDate, updateDeadLine)) { + this.title = updateTitle; + this.contents = updateContents; + this.startDate = updateStartDate; + this.deadLine = updateDeadLine; + } + } + + private boolean isUpdateRequired(String updateTitle, String updateContents, String updateStartDate, + String updateDeadLine) { + return !this.title.equals(updateTitle) || + !this.contents.equals(updateContents) || + !this.startDate.equals(updateStartDate) || + !this.deadLine.equals(updateDeadLine); + } + + public void progressUpdate(Progress progress) { + this.progress = progress; + } + + public void statusUpdate() { + this.status = (this.status == Status.ACTIVE) ? Status.DELETED : Status.ACTIVE; + } + + public void updateChallengeStatus(Status status) { + if (this.status == status) { + return; + } + this.status = status; + } + + public void sequenceUpdate(int sequence) { + this.sequence = sequence; + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Progress.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Progress.java new file mode 100644 index 00000000..3d77fd3e --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Progress.java @@ -0,0 +1,17 @@ +package shop.kkeujeok.kkeujeokbackend.block.domain; + +import lombok.Getter; + +@Getter +public enum Progress { + NOT_STARTED("시작 전"), + IN_PROGRESS("진행 중"), + COMPLETED("완료"); + + private final String description; + + Progress(String description) { + this.description = description; + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Type.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Type.java new file mode 100644 index 00000000..7d8cb699 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/Type.java @@ -0,0 +1,15 @@ +package shop.kkeujeok.kkeujeokbackend.block.domain; + +import lombok.Getter; + +@Getter +public enum Type { + BASIC("일반 블록"), + CHALLENGE("챌린지 블록"); + + private final String description; + + Type(String description) { + this.description = description; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockCustomRepository.java new file mode 100644 index 00000000..6f07e29b --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockCustomRepository.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.block.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public interface BlockCustomRepository { + Page findByBlockWithProgress(Long dashboardId, Progress progress, Pageable pageable); + + int findLastSequenceByProgress(Member member, Long dashboardId, Progress progress); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockCustomRepositoryImpl.java new file mode 100644 index 00000000..c26ba434 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockCustomRepositoryImpl.java @@ -0,0 +1,65 @@ +package shop.kkeujeok.kkeujeokbackend.block.domain.repository; + +import static shop.kkeujeok.kkeujeokbackend.block.domain.QBlock.block; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Repository +@Transactional(readOnly = true) +public class BlockCustomRepositoryImpl implements BlockCustomRepository { + private final JPAQueryFactory queryFactory; + + public BlockCustomRepositoryImpl(JPAQueryFactory queryFactory) { + this.queryFactory = queryFactory; + } + + @Override + public Page findByBlockWithProgress(Long dashboardId, Progress progress, Pageable pageable) { + long total = queryFactory + .selectFrom(block) + .where(block.progress.eq(progress)) + .stream() + .count(); + + List blocks = queryFactory + .selectFrom(block) + .where(block.dashboard.id.eq(dashboardId) + .and(block.progress.eq(progress)) + .and(block.status.eq(Status.ACTIVE))) + .orderBy(block.sequence.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(blocks, pageable, total); + } + + @Override + public int findLastSequenceByProgress(Member member, Long dashboardId, Progress progress) { + return Optional.of( + Math.toIntExact( + queryFactory + .select(block.sequence) + .from(block) + .where(block.dashboard.id.eq(dashboardId) + .and(block.progress.eq(progress)) + .and(block.status.eq(Status.ACTIVE))) + .stream() + .count() + ) + ) + .orElse(0); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockRepository.java new file mode 100644 index 00000000..9a9292a9 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockRepository.java @@ -0,0 +1,10 @@ +package shop.kkeujeok.kkeujeokbackend.block.domain.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; + +public interface BlockRepository extends JpaRepository, BlockCustomRepository { + List findByType(Type type); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/exception/BlockNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/exception/BlockNotFoundException.java new file mode 100644 index 00000000..b33e9e1a --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/exception/BlockNotFoundException.java @@ -0,0 +1,14 @@ +package shop.kkeujeok.kkeujeokbackend.block.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class BlockNotFoundException extends NotFoundGroupException { + + public BlockNotFoundException(String message) { + super(message); + } + + public BlockNotFoundException() { + this("존재하지 않는 블록 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/block/exception/InvalidProgressException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/exception/InvalidProgressException.java new file mode 100644 index 00000000..0caa38c7 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/block/exception/InvalidProgressException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.block.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.InvalidGroupException; + +public class InvalidProgressException extends InvalidGroupException { + public InvalidProgressException(String message) { + super(message); + } + + public InvalidProgressException() { + this("유효하지 않은 상태입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/ChallengeController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/ChallengeController.java new file mode 100644 index 00000000..62448dbc --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/ChallengeController.java @@ -0,0 +1,93 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.api; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSearchReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeInfoResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.application.ChallengeService; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/challenges") +public class ChallengeController { + + private final ChallengeService challengeService; + + @PostMapping + public RspTemplate save(@CurrentUserEmail String email, + @Valid @RequestBody ChallengeSaveReqDto challengeSaveReqDto) { + return new RspTemplate<>(HttpStatus.CREATED, "챌린지 생성 성공", challengeService.save(email, challengeSaveReqDto)); + } + + @PatchMapping("/{challengeId}") + public RspTemplate update(@CurrentUserEmail String email, + @PathVariable(name = "challengeId") Long challengeId, + @Valid @RequestBody ChallengeSaveReqDto challengeSaveReqDto) { + return new RspTemplate<>(HttpStatus.OK, "챌린지 수정 성공", + challengeService.update(email, challengeId, challengeSaveReqDto)); + } + + @GetMapping + public RspTemplate findAllChallenges(@RequestParam(defaultValue = "0", name = "page") int page, + @RequestParam(defaultValue = "10", name = "size") int size) { + return new RspTemplate<>(HttpStatus.OK, + "챌린지 전체 조회 성공", + challengeService.findAllChallenges(PageRequest.of(page, size))); + } + + @GetMapping("/search") + public RspTemplate findChallengesByKeyWord(@RequestParam(name = "keyword") String keyWord, + @RequestParam(defaultValue = "0", name = "page") int page, + @RequestParam(defaultValue = "10", name = "size") int size) { + ChallengeSearchReqDto searchReqDto = ChallengeSearchReqDto.from(keyWord); + return new RspTemplate<>(HttpStatus.OK, + "챌린지 검색 성공", + challengeService.findChallengesByKeyWord(searchReqDto, PageRequest.of(page, size))); + } + + @GetMapping("/find") + public RspTemplate findByCategory(@RequestParam(name = "category") String category, + @RequestParam(defaultValue = "0", name = "page") int page, + @RequestParam(defaultValue = "10", name = "size") int size) { + return new RspTemplate<>(HttpStatus.OK, + "챌린지 카테고리 검색 성공", + challengeService.findByCategory(category, PageRequest.of(page, size))); + } + + @GetMapping("/{challengeId}") + public RspTemplate findById(@PathVariable(name = "challengeId") Long challengeId) { + return new RspTemplate<>(HttpStatus.OK, "챌린지 상세보기", challengeService.findById(challengeId)); + } + + @DeleteMapping("/{challengeId}") + public RspTemplate delete(@CurrentUserEmail String email, + @PathVariable(name = "challengeId") Long challengeId) { + challengeService.delete(email, challengeId); + return new RspTemplate<>(HttpStatus.OK, "챌린지 삭제 성공"); + } + + @PostMapping("/{challengeId}/{dashboardId}") + public RspTemplate addChallengeToPersonalDashboard(@CurrentUserEmail String email, + @PathVariable(name = "challengeId") Long challengeId, + @PathVariable(name = "dashboardId") Long personalDashboardId) { + return new RspTemplate<>(HttpStatus.OK, + "챌린지 참여 성공", + challengeService.addChallengeToPersonalDashboard(email, challengeId, personalDashboardId)); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/reqeust/ChallengeSaveReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/reqeust/ChallengeSaveReqDto.java new file mode 100644 index 00000000..d2c5630b --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/reqeust/ChallengeSaveReqDto.java @@ -0,0 +1,76 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Category; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Cycle; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.InvalidCycleException; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public record ChallengeSaveReqDto( + @NotNull(message = "제목은 필수 입력값입니다.") + String title, + + @NotNull(message = "내용은 필수 입력값입니다.") + String contents, + + @NotNull(message = "카테고리는 필수 입력값입니다.") + Category category, + + @NotNull(message = "주기는 필수 입력값입니다.") + Cycle cycle, + + @NotNull(message = "주기 상세정보는 필수 입력값입니다.") + List cycleDetails, + + @NotNull(message = "시작 날짜는 필수 입력값입니다.") + LocalDate startDate, + + @NotNull(message = "종료 날짜는 필수 입력값입니다.") + LocalDate endDate, + + String representImage +) { + public Challenge toEntity(Member member) { + validateCycleDetails(); + return Challenge.builder() + .status(Status.ACTIVE) + .title(title) + .contents(contents) + .category(category) + .cycle(cycle) + .cycleDetails(cycleDetails) + .startDate(startDate) + .endDate(endDate) + .representImage(representImage) + .member(member) + .build(); + } + + private void validateCycleDetails() { + Set distinctCycleDetails = new HashSet<>(); + + cycleDetails.forEach(cycleDetail -> { + validateCycleDetailUniqueness(distinctCycleDetails, cycleDetail); + validateCycleDetailMatch(cycleDetail); + }); + } + + private void validateCycleDetailUniqueness(Set seenDetails, CycleDetail cycleDetail) { + if (!seenDetails.add(cycleDetail)) { + throw InvalidCycleException.forDuplicateDetail(); + } + } + + private void validateCycleDetailMatch(CycleDetail cycleDetail) { + if (cycleDetail.getCycle() != cycle) { + throw InvalidCycleException.forMismatchDetail(); + } + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/reqeust/ChallengeSearchReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/reqeust/ChallengeSearchReqDto.java new file mode 100644 index 00000000..9c1657a3 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/reqeust/ChallengeSearchReqDto.java @@ -0,0 +1,14 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust; + +import lombok.Builder; + +@Builder +public record ChallengeSearchReqDto( + String keyWord +) { + public static ChallengeSearchReqDto from(String keyWord) { + return ChallengeSearchReqDto.builder() + .keyWord(keyWord) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/response/ChallengeInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/response/ChallengeInfoResDto.java new file mode 100644 index 00000000..0f207999 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/response/ChallengeInfoResDto.java @@ -0,0 +1,38 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response; + +import java.time.LocalDate; +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Category; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Cycle; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; + +@Builder +public record ChallengeInfoResDto( + String title, + String contents, + Category category, + Cycle cycle, + List cycleDetails, + LocalDate startDate, + LocalDate endDate, + String representImage, + String authorName, + String authorProfileImage +) { + public static ChallengeInfoResDto from(Challenge challenge) { + return ChallengeInfoResDto.builder() + .title(challenge.getTitle()) + .contents(challenge.getContents()) + .category(challenge.getCategory()) + .cycle(challenge.getCycle()) + .cycleDetails(challenge.getCycleDetails()) + .startDate(challenge.getStartDate()) + .endDate(challenge.getEndDate()) + .representImage(challenge.getRepresentImage()) + .authorName(challenge.getMember().getNickname()) + .authorProfileImage(challenge.getMember().getPicture()) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/response/ChallengeListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/response/ChallengeListResDto.java new file mode 100644 index 00000000..929d6298 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/api/dto/response/ChallengeListResDto.java @@ -0,0 +1,19 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response; + +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +@Builder +public record ChallengeListResDto( + List challengeInfoResDto, + PageInfoResDto pageInfoResDto +) { + public static ChallengeListResDto of(List challengeInfoResDtoList, + PageInfoResDto pageInfoResDto) { + return ChallengeListResDto.builder() + .challengeInfoResDto(challengeInfoResDtoList) + .pageInfoResDto(pageInfoResDto) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/ChallengeService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/ChallengeService.java new file mode 100644 index 00000000..f7282b23 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/ChallengeService.java @@ -0,0 +1,190 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.application; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; +import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSearchReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeInfoResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.application.util.ChallengeBlockStatusUtil; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.repository.ChallengeRepository; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.ChallengeAccessDeniedException; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.ChallengeNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.exception.DashboardNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.repository.PersonalDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.exception.MemberNotFoundException; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; + +@Service +@RequiredArgsConstructor +public class ChallengeService { + + private final ChallengeRepository challengeRepository; + private final MemberRepository memberRepository; + private final PersonalDashboardRepository personalDashboardRepository; + private final BlockRepository blockRepository; + private final NotificationService notificationService; + + @Transactional + public ChallengeInfoResDto save(String email, ChallengeSaveReqDto challengeSaveReqDto) { + Member member = findMemberByEmail(email); + Challenge challenge = challengeSaveReqDto.toEntity(member); + + challengeRepository.save(challenge); + + return ChallengeInfoResDto.from(challenge); + } + + @Transactional + public ChallengeInfoResDto update(String email, Long challengeId, ChallengeSaveReqDto challengeSaveReqDto) { + Member member = findMemberByEmail(email); + Challenge challenge = findChallengeById(challengeId); + verifyMemberIsAuthor(challenge, member); + + challenge.update(challengeSaveReqDto.title(), + challengeSaveReqDto.contents(), + challengeSaveReqDto.cycleDetails(), + challengeSaveReqDto.startDate(), + challengeSaveReqDto.endDate(), + challengeSaveReqDto.representImage()); + + return ChallengeInfoResDto.from(challenge); + } + + @Transactional(readOnly = true) + public ChallengeListResDto findAllChallenges(Pageable pageable) { + Page challenges = challengeRepository.findAllChallenges(pageable); + + List challengeInfoResDtoList = challenges.stream() + .map(ChallengeInfoResDto::from) + .toList(); + + return ChallengeListResDto.of(challengeInfoResDtoList, PageInfoResDto.from(challenges)); + } + + @Transactional(readOnly = true) + public ChallengeListResDto findChallengesByKeyWord(ChallengeSearchReqDto challengeSearchReqDto, + Pageable pageable) { + Page challenges = challengeRepository.findChallengesByKeyWord(challengeSearchReqDto, pageable); + + List challengeInfoResDtoList = challenges.stream() + .map(ChallengeInfoResDto::from) + .toList(); + + return ChallengeListResDto.of(challengeInfoResDtoList, PageInfoResDto.from(challenges)); + } + + @Transactional(readOnly = true) + public ChallengeListResDto findByCategory(String category, Pageable pageable) { + Page challenges = challengeRepository.findChallengesByCategory(category, pageable); + + List challengeInfoResDtoList = challenges.stream() + .map(ChallengeInfoResDto::from) + .toList(); + + return ChallengeListResDto.of(challengeInfoResDtoList, PageInfoResDto.from(challenges)); + } + + @Transactional(readOnly = true) + public ChallengeInfoResDto findById(Long challengeId) { + Challenge challenge = findChallengeById(challengeId); + + return ChallengeInfoResDto.from(challenge); + } + + @Transactional + public void delete(String email, Long challengeId) { + Member member = findMemberByEmail(email); + Challenge challenge = findChallengeById(challengeId); + verifyMemberIsAuthor(challenge, member); + + challenge.updateStatus(); + } + + @Transactional + public BlockInfoResDto addChallengeToPersonalDashboard(String email, Long personalDashboardId, Long challengeId) { + Member member = findMemberByEmail(email); + Challenge challenge = findChallengeById(challengeId); + Dashboard personalDashboard = personalDashboardRepository.findById(personalDashboardId) + .orElseThrow(DashboardNotFoundException::new); + + Block block = createBlock(challenge, member, personalDashboard); + updateBlockStatusIfNotActive(block, challenge); + + blockRepository.save(block); + + String message = String.format("%s님이 챌린지에 참여했습니다", member.getName()); + notificationService.sendNotification(challenge.getMember(), message); + + return BlockInfoResDto.from(block); + } + + @Transactional(readOnly = true) + public ChallengeListResDto findChallengeForMemberId(String email, Pageable pageable) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + + Page challenges = challengeRepository.findChallengesByEmail(member, pageable); + + List challengeInfoResDtoList = challenges.stream() + .map(ChallengeInfoResDto::from) + .collect(Collectors.toList()); + + return ChallengeListResDto.of(challengeInfoResDtoList, PageInfoResDto.from(challenges)); + } + + private Block createBlock(Challenge challenge, Member member, Dashboard personalDashboard) { + return Block.builder() + .title(challenge.getTitle()) + .contents(challenge.getContents()) + .progress(Progress.NOT_STARTED) + .type(Type.CHALLENGE) + .deadLine(LocalDate.now() + .format(DateTimeFormatter.ofPattern("yyyy.MM.dd 23:59"))) + .member(member) + .dashboard(personalDashboard) + .challenge(challenge) + .build(); + } + + private void updateBlockStatusIfNotActive(Block block, Challenge challenge) { + if (!ChallengeBlockStatusUtil.isChallengeBlockActiveToday(challenge.getCycle(), challenge.getCycleDetails())) { + block.updateChallengeStatus(Status.UN_ACTIVE); + } + } + + private Member findMemberByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(MemberNotFoundException::new); + } + + private Challenge findChallengeById(Long challengeId) { + return challengeRepository.findById(challengeId) + .orElseThrow(ChallengeNotFoundException::new); + } + + + private void verifyMemberIsAuthor(Challenge challenge, Member member) { + if (!challenge.getMember().equals(member)) { + throw new ChallengeAccessDeniedException(); + } + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUpdateScheduler.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUpdateScheduler.java new file mode 100644 index 00000000..5891e115 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUpdateScheduler.java @@ -0,0 +1,48 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.application.util; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; +import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; + +@Component +@RequiredArgsConstructor +public class ChallengeBlockStatusUpdateScheduler { + + private final BlockRepository blockRepository; + private final NotificationService notificationService; // NotificationService 주입 + + @Scheduled(cron = "0 0 0 * * ?") + @Transactional + public void updateStatuses() { + List blocks = blockRepository.findByType(Type.CHALLENGE); + + blocks.forEach(block -> { + Status previousStatus = block.getStatus(); + Status newStatus; + + if (!ChallengeBlockStatusUtil.isChallengeBlockActiveToday(block.getChallenge().getCycle(), + block.getChallenge().getCycleDetails())) { + newStatus = Status.UN_ACTIVE; + } else { + newStatus = Status.ACTIVE; + } + + if (newStatus == Status.ACTIVE && previousStatus != Status.ACTIVE) { + block.updateChallengeStatus(newStatus); + + Member member = block.getMember(); + String message = String.format("%s 챌린지가 생성되었습니다", block.getChallenge().getTitle()); + + notificationService.sendNotification(member, message); + } + }); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUtil.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUtil.java new file mode 100644 index 00000000..8f6b8374 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUtil.java @@ -0,0 +1,29 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.application.util; + +import java.time.LocalDate; +import java.util.List; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Cycle; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; + +public class ChallengeBlockStatusUtil { + public static Boolean isChallengeBlockActiveToday(Cycle cycle, List cycleDetails) { + LocalDate today = LocalDate.now(); + int dayOfWeekNumber = today.getDayOfWeek().getValue(); + + return switch (cycle) { + case DAILY -> true; + case WEEKLY -> { + List activeDays = cycleDetails.stream() + .map(CycleDetail::getValue) + .toList(); + yield activeDays.contains(dayOfWeekNumber); + } + case MONTHLY -> { + List activeDays = cycleDetails.stream() + .map(CycleDetail::getValue) + .toList(); + yield activeDays.contains(today.getDayOfMonth()); + } + }; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/converter/CycleDetailsConverter.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/converter/CycleDetailsConverter.java new file mode 100644 index 00000000..0339e28c --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/converter/CycleDetailsConverter.java @@ -0,0 +1,36 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.type.CollectionType; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.List; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.InvalidCycleDetailsConversionException; + +@Converter(autoApply = true) +public class CycleDetailsConverter implements AttributeConverter, String> { + + private final ObjectMapper mapper = new ObjectMapper(); + private final CollectionType listType = mapper.getTypeFactory() + .constructCollectionType(List.class, CycleDetail.class); + + @Override + public String convertToDatabaseColumn(List cycleDetails) { + try { + return mapper.writeValueAsString(cycleDetails); + } catch (JsonProcessingException e) { + throw InvalidCycleDetailsConversionException.forConversionToDatabaseColumn(); + } + } + + @Override + public List convertToEntityAttribute(String data) { + try { + return mapper.readValue(data, listType); + } catch (JsonProcessingException e) { + throw InvalidCycleDetailsConversionException.forConversionToEntityAttribute(); + } + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Category.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Category.java new file mode 100644 index 00000000..35ee3d6c --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Category.java @@ -0,0 +1,17 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain; + +public enum Category { + + HEALTH_AND_FITNESS("건강 및 운동"), + MENTAL_WELLNESS("정신 및 마음관리"), + PRODUCTIVITY_AND_TIME_MANAGEMENT("생산성 및 시간 관리"), + FINANCE_AND_ASSET_MANAGEMENT("재정 및 자산 관리"), + SELF_DEVELOPMENT("자기 개발"), + LIFE_ORGANIZATION_AND_MANAGEMENT("생활 정리 및 관리"), + SOCIAL_CONNECTIONS("사회적 연결"), + CREATIVITY_AND_ARTS("창의력 및 예술 활동"), + OTHERS("기타"); + + Category(String description) { + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Challenge.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Challenge.java new file mode 100644 index 00000000..cad6b03f --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Challenge.java @@ -0,0 +1,105 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDate; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.challenge.converter.CycleDetailsConverter; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Challenge extends BaseEntity { + + @Enumerated(value = EnumType.STRING) + private Status status; + + private String title; + + @Column(columnDefinition = "TEXT") + private String contents; + + @Enumerated(value = EnumType.STRING) + private Category category; + + private Cycle cycle; + + @Convert(converter = CycleDetailsConverter.class) + @Column(name = "cycle_details") + private List cycleDetails; + + @Column(name = "start_date") + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + + @Column(name = "represent_image") + private String representImage; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @Builder + private Challenge(Status status, + String title, + String contents, + Category category, + Cycle cycle, + List cycleDetails, + LocalDate startDate, + LocalDate endDate, + String representImage, + Member member) { + this.status = status; + this.title = title; + this.contents = contents; + this.category = category; + this.cycle = cycle; + this.cycleDetails = cycleDetails; + this.startDate = startDate; + this.endDate = endDate; + this.representImage = representImage; + this.member = member; + } + + public void update(String updateTitle, String updateContents, List updateCycleDetails, + LocalDate updateStartDate, LocalDate updateEndDate, String updateRepresentImage) { + if (hasChanges(updateTitle, updateContents, updateCycleDetails, updateStartDate, updateEndDate, + updateRepresentImage)) { + this.title = updateTitle; + this.contents = updateContents; + this.cycleDetails = updateCycleDetails; + this.startDate = updateStartDate; + this.endDate = updateEndDate; + this.representImage = updateRepresentImage; + } + } + + private boolean hasChanges(String updateTitle, String updateContents, List updateCycleDetails, + LocalDate updateStartDate, LocalDate updateEndDate, String updateRepresentImage) { + return !this.title.equals(updateTitle) || + !this.contents.equals(updateContents) || + !this.cycleDetails.equals(updateCycleDetails) || + !this.startDate.equals(updateStartDate) || + !this.endDate.equals(updateEndDate) || + !this.representImage.equals(updateRepresentImage); + } + + public void updateStatus() { + this.status = (this.status == Status.ACTIVE) ? Status.DELETED : Status.ACTIVE; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Cycle.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Cycle.java new file mode 100644 index 00000000..d996c168 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/Cycle.java @@ -0,0 +1,16 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain; + +import lombok.Getter; + +@Getter +public enum Cycle { + DAILY("매일"), + WEEKLY("매주"), + MONTHLY("매달"); + + private final String description; + + Cycle(String description) { + this.description = description; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/CycleDetail.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/CycleDetail.java new file mode 100644 index 00000000..af30aaaf --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/CycleDetail.java @@ -0,0 +1,40 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain; + +import lombok.Getter; + +@Getter +public enum CycleDetail { + DAILY("매일", Cycle.DAILY, 0), + + MON("월요일", Cycle.WEEKLY, 1), TUE("화요일", Cycle.WEEKLY, 2), WED("수요일", Cycle.WEEKLY, 3), + THU("목요일", Cycle.WEEKLY, 4), FRI("금요일", Cycle.WEEKLY, 5), SAT("토요일", Cycle.WEEKLY, 6), + SUN("일요일", Cycle.WEEKLY, 7), + + FIRST("1일", Cycle.MONTHLY, 1), SECOND("2일", Cycle.MONTHLY, 2), THIRD("3일", Cycle.MONTHLY, 3), + FOURTH("4일", Cycle.MONTHLY, 4), FIFTH("5일", Cycle.MONTHLY, 5), SIXTH("6일", Cycle.MONTHLY, 6), + SEVENTH("7일", Cycle.MONTHLY, 7), EIGHTH("8일", Cycle.MONTHLY, 8), NINTH("9일", Cycle.MONTHLY, 9), + TENTH("10일", Cycle.MONTHLY, 10), ELEVENTH("11일", Cycle.MONTHLY, 11), TWELFTH("12일", Cycle.MONTHLY, 12), + THIRTEENTH("13일", Cycle.MONTHLY, 13), FOURTEENTH("14일", Cycle.MONTHLY, 14), FIFTEENTH("15일", Cycle.MONTHLY, + 15), + SIXTEENTH("16일", Cycle.MONTHLY, 16), SEVENTEENTH("17일", Cycle.MONTHLY, 17), EIGHTEENTH("18일", Cycle.MONTHLY, + 18), + NINETEENTH("19일", Cycle.MONTHLY, 19), TWENTIETH("20일", Cycle.MONTHLY, 20), TWENTY_FIRST("21일", Cycle.MONTHLY, + 21), + TWENTY_SECOND("22일", Cycle.MONTHLY, 22), TWENTY_THIRD("23일", Cycle.MONTHLY, + 23), TWENTY_FOURTH("24일", Cycle.MONTHLY, 24), + TWENTY_FIFTH("25일", Cycle.MONTHLY, 25), TWENTY_SIXTH("26일", Cycle.MONTHLY, + 26), TWENTY_SEVENTH("27일", Cycle.MONTHLY, 27), + TWENTY_EIGHTH("28일", Cycle.MONTHLY, 28), TWENTY_NINTH("29일", Cycle.MONTHLY, + 29), THIRTIETH("30일", Cycle.MONTHLY, 30), + THIRTY_FIRST("31일", Cycle.MONTHLY, 31); + + private final String description; + private final Cycle cycle; + private final int value; + + CycleDetail(String description, Cycle cycle, int value) { + this.description = description; + this.cycle = cycle; + this.value = value; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeCustomRepository.java new file mode 100644 index 00000000..3fe3e11e --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeCustomRepository.java @@ -0,0 +1,17 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSearchReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public interface ChallengeCustomRepository { + Page findAllChallenges(Pageable pageable); + + Page findChallengesByKeyWord(ChallengeSearchReqDto challengeSearchReqDto, Pageable pageable); + + Page findChallengesByEmail(Member member, Pageable pageable); + + Page findChallengesByCategory(String category, Pageable pageable); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeCustomRepositoryImpl.java new file mode 100644 index 00000000..ff6281fd --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeCustomRepositoryImpl.java @@ -0,0 +1,107 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain.repository; + +import static shop.kkeujeok.kkeujeokbackend.challenge.domain.QChallenge.challenge; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSearchReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Category; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Repository +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ChallengeCustomRepositoryImpl implements ChallengeCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findAllChallenges(Pageable pageable) { + long total = Optional.ofNullable( + queryFactory + .select(challenge.count()) + .from(challenge) + .where(challenge.status.eq(Status.ACTIVE)) + .fetchOne() + ).orElse(0L); + + List challenges = queryFactory + .selectFrom(challenge) + .where(challenge.status.eq(Status.ACTIVE)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(challenges, pageable, total); + } + + @Override + public Page findChallengesByKeyWord(ChallengeSearchReqDto challengeSearchReqDto, Pageable pageable) { + long total = Optional.ofNullable( + queryFactory + .select(challenge.count()) + .from(challenge) + .where(challenge.status.eq(Status.ACTIVE), + challenge.title.containsIgnoreCase(challengeSearchReqDto.keyWord())) + .fetchOne() + ).orElse(0L); + + List challenges = queryFactory + .selectFrom(challenge) + .where(challenge.status.eq(Status.ACTIVE), + challenge.title.containsIgnoreCase(challengeSearchReqDto.keyWord())) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(challenges, pageable, total); + } + + @Override + public Page findChallengesByEmail(Member member, Pageable pageable) { + long total = queryFactory + .selectFrom(challenge) + .where(challenge.member.eq(member)) + .stream() + .count(); + + List challenges = queryFactory + .selectFrom(challenge) + .where(challenge.member.eq(member) + .and(challenge.status.eq(Status.ACTIVE))) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(challenges, pageable, total); + } + + public Page findChallengesByCategory(String category, Pageable pageable) { + long total = Optional.ofNullable( + queryFactory + .select(challenge.count()) + .from(challenge) + .where(challenge.status.eq(Status.ACTIVE), + challenge.category.eq(Category.valueOf(category))) + .fetchOne() + ).orElse(0L); + + List challenges = queryFactory + .selectFrom(challenge) + .where(challenge.status.eq(Status.ACTIVE), + challenge.category.eq(Category.valueOf(category))) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + return new PageImpl<>(challenges, pageable, total); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeRepository.java new file mode 100644 index 00000000..7fee3fc5 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/repository/ChallengeRepository.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; + +public interface ChallengeRepository extends JpaRepository, ChallengeCustomRepository { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/ChallengeAccessDeniedException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/ChallengeAccessDeniedException.java new file mode 100644 index 00000000..437b4c3e --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/ChallengeAccessDeniedException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.AccessDeniedGroupException; + +public class ChallengeAccessDeniedException extends AccessDeniedGroupException { + public ChallengeAccessDeniedException(String message) { + super(message); + } + + public ChallengeAccessDeniedException() { + this("챌린지 작성자가 아닙니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/ChallengeNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/ChallengeNotFoundException.java new file mode 100644 index 00000000..4fa22834 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/ChallengeNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class ChallengeNotFoundException extends NotFoundGroupException { + public ChallengeNotFoundException(String message) { + super(message); + } + + public ChallengeNotFoundException() { + this("존재하지 않는 챌린지입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/CycleDetailsNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/CycleDetailsNotFoundException.java new file mode 100644 index 00000000..a0ed264a --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/CycleDetailsNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class CycleDetailsNotFoundException extends NotFoundGroupException { + public CycleDetailsNotFoundException(String message) { + super(message); + } + + public CycleDetailsNotFoundException() { + this("주기 세부정보가 존재하지 않습니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/InvalidCycleDetailsConversionException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/InvalidCycleDetailsConversionException.java new file mode 100644 index 00000000..d58cc9ca --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/InvalidCycleDetailsConversionException.java @@ -0,0 +1,22 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.InvalidGroupException; + +public class InvalidCycleDetailsConversionException extends InvalidGroupException { + private static final String CONVERSION_TO_DATABASE_COLUMN_MESSAGE = + "List를 JSON 문자열로 변환하는 중 오류가 발생했습니다."; + private static final String CONVERSION_TO_ENTITY_ATTRIBUTE_MESSAGE = + "JSON 문자열을 List로 변환하는 중 오류가 발생했습니다."; + + public InvalidCycleDetailsConversionException(String message) { + super(message); + } + + public static InvalidCycleDetailsConversionException forConversionToDatabaseColumn() { + return new InvalidCycleDetailsConversionException(CONVERSION_TO_DATABASE_COLUMN_MESSAGE); + } + + public static InvalidCycleDetailsConversionException forConversionToEntityAttribute() { + return new InvalidCycleDetailsConversionException(CONVERSION_TO_ENTITY_ATTRIBUTE_MESSAGE); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/InvalidCycleException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/InvalidCycleException.java new file mode 100644 index 00000000..48cfbb53 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/challenge/exception/InvalidCycleException.java @@ -0,0 +1,20 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.InvalidGroupException; + +public class InvalidCycleException extends InvalidGroupException { + private static final String DUPLICATE_DETAIL_MESSAGE = "중복된 주기 세부 항목이 발견되었습니다: %s %s"; + private static final String MISMATCH_DETAIL_MESSAGE = "주기와 일치하지 않는 세부 항목이 있습니다: %s %s"; + + public InvalidCycleException(String message) { + super(message); + } + + public static InvalidCycleException forDuplicateDetail() { + return new InvalidCycleException(DUPLICATE_DETAIL_MESSAGE); + } + + public static InvalidCycleException forMismatchDetail() { + return new InvalidCycleException(MISMATCH_DETAIL_MESSAGE); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/Dashboard.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/Dashboard.java new file mode 100644 index 00000000..3a9d7806 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/Dashboard.java @@ -0,0 +1,66 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@DiscriminatorColumn(name = "dtype") +@Inheritance(strategy = InheritanceType.JOINED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Dashboard extends BaseEntity { + + @Enumerated(value = EnumType.STRING) + private Status status; + + private String title; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "dtype", insertable = false, updatable = false) + private String dType; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @OneToMany(mappedBy = "dashboard", cascade = CascadeType.ALL, orphanRemoval = true) + private List blocks = new ArrayList<>(); + + public Dashboard(String title, String description, String dType, Member member) { + this.status = Status.ACTIVE; + this.title = title; + this.description = description; + this.dType = dType; + this.member = member; + } + + public void update(String updateTitle, String updateDescription) { + this.title = updateTitle; + this.description = updateDescription; + } + + public void statusUpdate(Status status) { + this.status = status; + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardCustomRepository.java new file mode 100644 index 00000000..4dde6ece --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardCustomRepository.java @@ -0,0 +1,24 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository; + +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public interface DashboardCustomRepository { + + List findForPersonalDashboard(Member member); + + List findForPersonalDashboardByCategory(Member member); + + Page findForTeamDashboard(Member member, Pageable pageable); + + List findForTeamDashboard(Member member); + + List findForMembersByQuery(String query); + + double calculateCompletionPercentage(Long dashboardId); + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardCustomRepositoryImpl.java new file mode 100644 index 00000000..448f887a --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardCustomRepositoryImpl.java @@ -0,0 +1,119 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository; + +import static shop.kkeujeok.kkeujeokbackend.block.domain.QBlock.block; +import static shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.QPersonalDashboard.personalDashboard; +import static shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.QTeamDashboard.teamDashboard; +import static shop.kkeujeok.kkeujeokbackend.member.domain.QMember.member; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Repository +@Transactional(readOnly = true) +public class DashboardCustomRepositoryImpl implements DashboardCustomRepository { + + private final JPAQueryFactory queryFactory; + + public DashboardCustomRepositoryImpl(JPAQueryFactory queryFactory) { + this.queryFactory = queryFactory; + } + + @Override + public List findForPersonalDashboard(Member member) { + return queryFactory + .selectFrom(personalDashboard) + .where(personalDashboard._super.member.eq(member) + .and(personalDashboard._super.status.eq(Status.ACTIVE))) + .fetch(); + } + + + @Override + public List findForPersonalDashboardByCategory(Member member) { + return queryFactory + .select(personalDashboard.category) + .from(personalDashboard) + .where(personalDashboard._super.member.eq(member)) + .stream() + .toList(); + } + + @Override + public Page findForTeamDashboard(Member member, Pageable pageable) { + long total = queryFactory + .selectFrom(teamDashboard) + .where(teamDashboard._super.member.eq(member)) + .stream() + .count(); + + List dashboards = queryFactory + .selectFrom(teamDashboard) + .where(teamDashboard._super.member.eq(member) + .and(teamDashboard._super.status.eq(Status.ACTIVE))) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(dashboards, pageable, total); + } + + @Override + public List findForTeamDashboard(Member member) { + return queryFactory + .selectFrom(teamDashboard) + .where(teamDashboard._super.member.eq(member) + .and(teamDashboard._super.status.eq(Status.ACTIVE))) + .fetch(); + } + + @Override + public List findForMembersByQuery(String query) { + if (query.contains("#")) { + String[] parts = query.split("#"); + String nickname = parts[0]; + String tag = "#" + parts[1]; + + return queryFactory + .selectFrom(member) + .where(member.nickname.eq(nickname) + .and(member.tag.eq(tag))) + .fetch(); + } + + return queryFactory + .selectFrom(member) + .where(member.email.eq(query)) + .fetch(); + } + + @Override + public double calculateCompletionPercentage(Long dashboardId) { + List blocks = queryFactory + .selectFrom(block) + .where(block.dashboard.id.eq(dashboardId)) + .fetch(); + + long totalBlocks = blocks.size(); + + long completedBlocks = blocks.stream() + .filter(b -> b.getProgress().equals(Progress.COMPLETED)) + .count(); + + if (totalBlocks == 0) { + return 0; + } + + return (double) completedBlocks / totalBlocks * 100; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardRepository.java new file mode 100644 index 00000000..33192ed8 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/domain/repository/DashboardRepository.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; + +public interface DashboardRepository extends JpaRepository, DashboardCustomRepository { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/exception/DashboardNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/exception/DashboardNotFoundException.java new file mode 100644 index 00000000..731f76f8 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/exception/DashboardNotFoundException.java @@ -0,0 +1,14 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class DashboardNotFoundException extends NotFoundGroupException { + + public DashboardNotFoundException(String message) { + super(message); + } + + public DashboardNotFoundException() { + this("존재하지 않는 대시보드 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/PersonalDashboardController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/PersonalDashboardController.java new file mode 100644 index 00000000..3b1538a6 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/PersonalDashboardController.java @@ -0,0 +1,75 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardCategoriesResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.application.PersonalDashboardService; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/dashboards/personal") +public class PersonalDashboardController { + + private final PersonalDashboardService personalDashboardService; + + @PostMapping("/") + public RspTemplate save(@CurrentUserEmail String email, + @RequestBody @Valid PersonalDashboardSaveReqDto personalDashboardSaveReqDto) { + return new RspTemplate<>(HttpStatus.OK, + "개인 대시보드 생성", + personalDashboardService.save(email, personalDashboardSaveReqDto)); + } + + @PatchMapping("/{dashboardId}") + public RspTemplate update(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId, + @RequestBody PersonalDashboardUpdateReqDto personalDashboardUpdateReqDto) { + return new RspTemplate<>(HttpStatus.OK, + "개인 대시보드 수정", + personalDashboardService.update(email, dashboardId, personalDashboardUpdateReqDto)); + } + + @GetMapping("/") + public RspTemplate findForPersonalDashboard(@CurrentUserEmail String email) { + return new RspTemplate<>(HttpStatus.OK, + "개인 대시보드 전체 조회", + personalDashboardService.findForPersonalDashboard(email)); + } + + @GetMapping("/{dashboardId}") + public RspTemplate findById(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId) { + return new RspTemplate<>(HttpStatus.OK, "개인 대시보드 상세보기", personalDashboardService.findById(email, dashboardId)); + } + + @GetMapping("/categories") + public RspTemplate findForPersonalDashboardByCategories( + @CurrentUserEmail String email) { + return new RspTemplate<>(HttpStatus.OK, + "개인 대시보드 카테고리 조회", + personalDashboardService.findForPersonalDashboardByCategories(email)); + } + + @DeleteMapping("/{dashboardId}") + public RspTemplate delete(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId) { + personalDashboardService.delete(email, dashboardId); + return new RspTemplate<>(HttpStatus.OK, "개인 대시보드 삭제, 복구"); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/request/PersonalDashboardSaveReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/request/PersonalDashboardSaveReqDto.java new file mode 100644 index 00000000..818f8ae0 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/request/PersonalDashboardSaveReqDto.java @@ -0,0 +1,29 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public record PersonalDashboardSaveReqDto( + @NotBlank(message = "필수 입력값 입니다.") + String title, + + @NotBlank(message = "필수 입력값 입니다.") + @Size(max = 300) + String description, + + boolean isPublic, + + String category +) { + public PersonalDashboard toEntity(Member member) { + return PersonalDashboard.builder() + .title(title) + .description(description) + .member(member) + .isPublic(isPublic) + .category(category) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/request/PersonalDashboardUpdateReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/request/PersonalDashboardUpdateReqDto.java new file mode 100644 index 00000000..576ef873 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/request/PersonalDashboardUpdateReqDto.java @@ -0,0 +1,9 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request; + +public record PersonalDashboardUpdateReqDto( + String title, + String description, + boolean isPublic, + String category +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardCategoriesResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardCategoriesResDto.java new file mode 100644 index 00000000..9b83a821 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardCategoriesResDto.java @@ -0,0 +1,15 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response; + +import java.util.List; +import lombok.Builder; + +@Builder +public record PersonalDashboardCategoriesResDto( + List categories +) { + public static PersonalDashboardCategoriesResDto from(List categories) { + return PersonalDashboardCategoriesResDto.builder() + .categories(categories) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardInfoResDto.java new file mode 100644 index 00000000..f648b431 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardInfoResDto.java @@ -0,0 +1,43 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Builder +public record PersonalDashboardInfoResDto( + Long dashboardId, + Long myId, + Long creatorId, + String title, + String description, + boolean isPublic, + String category, + double blockProgress +) { + public static PersonalDashboardInfoResDto of(Member member, PersonalDashboard dashboard) { + return commonBuilder(member, dashboard) + .build(); + } + + public static PersonalDashboardInfoResDto detailOf(Member member, + PersonalDashboard dashboard, + double blockProgress) { + return commonBuilder(member, dashboard) + .blockProgress(blockProgress) + .build(); + } + + private static PersonalDashboardInfoResDtoBuilder commonBuilder(Member member, + PersonalDashboard dashboard) { + return PersonalDashboardInfoResDto.builder() + .dashboardId(dashboard.getId()) + .myId(member.getId()) + .creatorId(dashboard.getMember().getId()) + .title(dashboard.getTitle()) + .description(dashboard.getDescription()) + .isPublic(dashboard.isPublic()) + .category(dashboard.getCategory()); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardListResDto.java new file mode 100644 index 00000000..de0e4c9a --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/dto/response/PersonalDashboardListResDto.java @@ -0,0 +1,15 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response; + +import java.util.List; +import lombok.Builder; + +@Builder +public record PersonalDashboardListResDto( + List personalDashboardListResDto +) { + public static PersonalDashboardListResDto of(List personalDashboards) { + return PersonalDashboardListResDto.builder() + .personalDashboardListResDto(personalDashboards) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/application/PersonalDashboardService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/application/PersonalDashboardService.java new file mode 100644 index 00000000..9138aeed --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/application/PersonalDashboardService.java @@ -0,0 +1,108 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.application; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.dashboard.exception.DashboardNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardCategoriesResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.repository.PersonalDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.exception.DashboardAccessDeniedException; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.exception.MemberNotFoundException; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PersonalDashboardService { + + private final PersonalDashboardRepository personalDashboardRepository; + private final MemberRepository memberRepository; + + // 개인 대시보드 저장 + @Transactional + public PersonalDashboardInfoResDto save(String email, PersonalDashboardSaveReqDto personalDashboardSaveReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + PersonalDashboard dashboard = personalDashboardSaveReqDto.toEntity(member); + + personalDashboardRepository.save(dashboard); + + return PersonalDashboardInfoResDto.of(member, dashboard); + } + + // 개인 대시보드 수정 + @Transactional + public PersonalDashboardInfoResDto update(String email, + Long dashboardId, + PersonalDashboardUpdateReqDto personalDashboardUpdateReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + PersonalDashboard dashboard = personalDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + verifyMemberIsAuthor(dashboard, member); + + dashboard.update(personalDashboardUpdateReqDto.title(), + personalDashboardUpdateReqDto.description(), + personalDashboardUpdateReqDto.isPublic(), + personalDashboardUpdateReqDto.category()); + + return PersonalDashboardInfoResDto.of(member, dashboard); + } + + // 개인 대시보드 전체 조회 + public PersonalDashboardListResDto findForPersonalDashboard(String email) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + List personalDashboards = personalDashboardRepository.findForPersonalDashboard(member); + + List personalDashboardInfoResDtoList = personalDashboards.stream() + .map(p -> PersonalDashboardInfoResDto.of(member, p)) + .toList(); + + return PersonalDashboardListResDto.of(personalDashboardInfoResDtoList); + } + + // 개인 대시보드 상세조회 + public PersonalDashboardInfoResDto findById(String email, Long dashboardId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + PersonalDashboard dashboard = personalDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + double blockProgress = personalDashboardRepository.calculateCompletionPercentage(dashboard.getId()); + + return PersonalDashboardInfoResDto.detailOf(member, dashboard, blockProgress); + } + + // 개인 대시보드 카테고리 조회 + public PersonalDashboardCategoriesResDto findForPersonalDashboardByCategories(String email) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + + List categories = personalDashboardRepository.findForPersonalDashboardByCategory(member); + + return PersonalDashboardCategoriesResDto.from(categories); + } + + // 개인 대시보드 삭제 유무 업데이트 (논리 삭제) + @Transactional + public void delete(String email, Long dashboardId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + PersonalDashboard dashboard = personalDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + verifyMemberIsAuthor(dashboard, member); + + dashboard.statusUpdate(); + } + + private void verifyMemberIsAuthor(PersonalDashboard dashboard, Member member) { + if (!member.equals(dashboard.getMember())) { + throw new DashboardAccessDeniedException(); + } + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/PersonalDashboard.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/PersonalDashboard.java new file mode 100644 index 00000000..a86fcf9b --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/PersonalDashboard.java @@ -0,0 +1,39 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain; + +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PersonalDashboard extends Dashboard { + + public boolean isPublic; + + private String category; + + @Builder + private PersonalDashboard(String title, String description, String dType, Member member, boolean isPublic, + String category) { + super(title, description, dType, member); + this.category = category; + this.isPublic = isPublic; + } + + public void update(String updateTitle, String updateDescription, boolean updateIsPublic, String updateCategory) { + super.update(updateTitle, updateDescription); + this.isPublic = updateIsPublic; + this.category = updateCategory; + } + + public void statusUpdate() { + super.statusUpdate((super.getStatus() == Status.ACTIVE) ? Status.DELETED : Status.ACTIVE); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/repository/PersonalDashboardRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/repository/PersonalDashboardRepository.java new file mode 100644 index 00000000..b1e5259d --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/repository/PersonalDashboardRepository.java @@ -0,0 +1,9 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository.DashboardCustomRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; + +public interface PersonalDashboardRepository extends JpaRepository, DashboardCustomRepository { + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/exception/DashboardAccessDeniedException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/exception/DashboardAccessDeniedException.java new file mode 100644 index 00000000..b3263078 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/exception/DashboardAccessDeniedException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.AccessDeniedGroupException; + +public class DashboardAccessDeniedException extends AccessDeniedGroupException { + public DashboardAccessDeniedException(String message) { + super(message); + } + + public DashboardAccessDeniedException() { + this("대시보드 생성자가 아닙니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/TeamDashboardController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/TeamDashboardController.java new file mode 100644 index 00000000..caf6e792 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/TeamDashboardController.java @@ -0,0 +1,87 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.SearchMemberListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.application.TeamDashboardService; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/dashboards/team") +public class TeamDashboardController { + + private final TeamDashboardService teamDashboardService; + + @PostMapping("/") + public RspTemplate save(@CurrentUserEmail String email, + @RequestBody @Valid TeamDashboardSaveReqDto teamDashboardSaveReqDto) { + return new RspTemplate<>(HttpStatus.OK, + "팀 대시보드 생성", + teamDashboardService.save(email, teamDashboardSaveReqDto)); + } + + @PatchMapping("/{dashboardId}") + public RspTemplate update(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId, + @RequestBody @Valid TeamDashboardUpdateReqDto teamDashboardUpdateReqDto) { + return new RspTemplate<>(HttpStatus.OK, + "팀 대시보드 수정", + teamDashboardService.update(email, dashboardId, teamDashboardUpdateReqDto)); + } + + @GetMapping("/") + public RspTemplate findForTeamDashboard(@CurrentUserEmail String email) { + return new RspTemplate<>(HttpStatus.OK, + "팀 대시보드 전체 조회", + teamDashboardService.findForTeamDashboard(email)); + } + + @GetMapping("/{dashboardId}") + public RspTemplate findById(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId) { + return new RspTemplate<>(HttpStatus.OK, "팀 대시보드 상세보기", teamDashboardService.findById(email, dashboardId)); + } + + @DeleteMapping("/{dashboardId}") + public RspTemplate delete(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId) { + teamDashboardService.delete(email, dashboardId); + return new RspTemplate<>(HttpStatus.OK, "팀 대시보드 삭제, 복구"); + } + + @PostMapping("/{dashboardId}/join") + public RspTemplate joinTeam(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId) { + teamDashboardService.joinTeam(email, dashboardId); + return new RspTemplate<>(HttpStatus.OK, "팀 가입"); + } + + @PostMapping("/{dashboardId}/leave") + public RspTemplate leaveTeam(@CurrentUserEmail String email, + @PathVariable(name = "dashboardId") Long dashboardId) { + teamDashboardService.leaveTeam(email, dashboardId); + return new RspTemplate<>(HttpStatus.OK, "팀 탈퇴"); + } + + @GetMapping("/search") + public RspTemplate search(@RequestParam(name = "query") String query) { + return new RspTemplate<>(HttpStatus.OK, "팀원 초대 리스트", teamDashboardService.searchMembers(query)); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/request/TeamDashboardSaveReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/request/TeamDashboardSaveReqDto.java new file mode 100644 index 00000000..7423c6fd --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/request/TeamDashboardSaveReqDto.java @@ -0,0 +1,23 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +public record TeamDashboardSaveReqDto( + @NotBlank(message = "필수 입력값 입니다.") + String title, + + @NotBlank(message = "필수 입력값 입니다.") + @Size(max = 300) + String description +) { + public TeamDashboard toEntity(Member member) { + return TeamDashboard.builder() + .title(title) + .description(description) + .member(member) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/request/TeamDashboardUpdateReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/request/TeamDashboardUpdateReqDto.java new file mode 100644 index 00000000..db01994f --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/request/TeamDashboardUpdateReqDto.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request; + +public record TeamDashboardUpdateReqDto( + String title, + String description +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/SearchMemberListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/SearchMemberListResDto.java new file mode 100644 index 00000000..b4318819 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/SearchMemberListResDto.java @@ -0,0 +1,34 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response; + +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Builder +public record SearchMemberListResDto( + List searchMembers +) { + public static SearchMemberListResDto from(List members) { + return SearchMemberListResDto.builder() + .searchMembers(members.stream() + .map(SearchMemberInfoResDto::from) + .toList()) + .build(); + } + + @Builder + private record SearchMemberInfoResDto( + Long id, + String picture, + String email + ) { + private static SearchMemberInfoResDto from(Member member) { + return SearchMemberInfoResDto.builder() + .id(member.getId()) + .picture(member.getPicture()) + .email(member.getEmail()) + .build(); + } + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/TeamDashboardInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/TeamDashboardInfoResDto.java new file mode 100644 index 00000000..cdf94b61 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/TeamDashboardInfoResDto.java @@ -0,0 +1,65 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response; + +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +@Builder +public record TeamDashboardInfoResDto( + Long dashboardId, + Long myId, + Long creatorId, + String title, + String description, + double blockProgress, + List joinMembers +) { + public static TeamDashboardInfoResDto of(Member member, TeamDashboard dashboard) { + return commonBuilder(member, dashboard) + .build(); + } + + public static TeamDashboardInfoResDto detailOf(Member member, TeamDashboard dashboard, double blockProgress) { + return commonBuilder(member, dashboard) + .blockProgress(blockProgress) + .joinMembers(dashboard.getTeamDashboardMemberMappings().stream() + .map(teamDashboardMemberMapping -> { + return JoinMemberInfoResDto.from(teamDashboardMemberMapping.getMember()); + }) + .toList()) + .build(); + } + + public static TeamDashboardInfoResDtoBuilder commonBuilder(Member member, TeamDashboard dashboard) { + return TeamDashboardInfoResDto.builder() + .dashboardId(dashboard.getId()) + .myId(member.getId()) + .creatorId(dashboard.getMember().getId()) + .title(dashboard.getTitle()) + .description(dashboard.getDescription()); + } + + @Builder + private record JoinMemberInfoResDto( + String picture, + String email, + String name, + String nickName, + SocialType socialType, + String introduction + ) { + private static JoinMemberInfoResDto from(Member member) { + return JoinMemberInfoResDto.builder() + .picture(member.getPicture()) + .email(member.getEmail()) + .name(member.getName()) + .nickName(member.getNickname()) + .socialType(member.getSocialType()) + .introduction(member.getIntroduction()) + .build(); + } + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/TeamDashboardListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/TeamDashboardListResDto.java new file mode 100644 index 00000000..d05086ad --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/dto/response/TeamDashboardListResDto.java @@ -0,0 +1,25 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response; + +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +@Builder +public record TeamDashboardListResDto( + List teamDashboardInfoResDto, + PageInfoResDto pageInfoResDto +) { + public static TeamDashboardListResDto of(List teamDashboards, + PageInfoResDto pageInfoResDto) { + return TeamDashboardListResDto.builder() + .teamDashboardInfoResDto(teamDashboards) + .pageInfoResDto(pageInfoResDto) + .build(); + } + + public static TeamDashboardListResDto from(List teamDashboards) { + return TeamDashboardListResDto.builder() + .teamDashboardInfoResDto(teamDashboards) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/application/TeamDashboardService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/application/TeamDashboardService.java new file mode 100644 index 00000000..7206203f --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/application/TeamDashboardService.java @@ -0,0 +1,136 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.application; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.dashboard.exception.DashboardNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.exception.DashboardAccessDeniedException; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.SearchMemberListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository.TeamDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.exception.MemberNotFoundException; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TeamDashboardService { + + private final TeamDashboardRepository teamDashboardRepository; + private final MemberRepository memberRepository; + + // 팀 대시보드 저장 + @Transactional + public TeamDashboardInfoResDto save(String email, TeamDashboardSaveReqDto teamDashboardSaveReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + TeamDashboard teamDashboard = teamDashboardSaveReqDto.toEntity(member); + + teamDashboardRepository.save(teamDashboard); + + return TeamDashboardInfoResDto.of(member, teamDashboard); + } + + // 팀 대시보드 수정 + @Transactional + public TeamDashboardInfoResDto update(String email, + Long dashboardId, + TeamDashboardUpdateReqDto teamDashboardUpdateReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + TeamDashboard dashboard = teamDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + verifyMemberIsAuthor(dashboard, member); + + dashboard.update(teamDashboardUpdateReqDto.title(), + teamDashboardUpdateReqDto.description()); + + return TeamDashboardInfoResDto.of(member, dashboard); + } + + // 팀 대시보드 전체 조회(페이지네이션) + public TeamDashboardListResDto findForTeamDashboard(String email, Pageable pageable) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + Page teamDashboards = teamDashboardRepository.findForTeamDashboard(member, pageable); + + List teamDashboardInfoResDtoList = teamDashboards.stream() + .map(t -> TeamDashboardInfoResDto.of(member, t)) + .toList(); + + return TeamDashboardListResDto + .of(teamDashboardInfoResDtoList, PageInfoResDto.from(teamDashboards)); + } + + // 팀 대시보드 전체 조회(페이지네이션 X) + public TeamDashboardListResDto findForTeamDashboard(String email) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + List teamDashboards = teamDashboardRepository.findForTeamDashboard(member); + + List teamDashboardInfoResDtoList = teamDashboards.stream() + .map(t -> TeamDashboardInfoResDto.of(member, t)) + .toList(); + + return TeamDashboardListResDto.from(teamDashboardInfoResDtoList); + } + + // 팀 대시보드 상세 조회 + public TeamDashboardInfoResDto findById(String email, Long dashboardId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + TeamDashboard dashboard = teamDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + double blockProgress = teamDashboardRepository.calculateCompletionPercentage(dashboard.getId()); + + return TeamDashboardInfoResDto.detailOf(member, dashboard, blockProgress); + } + + // 팀 대시보드 삭제 유무 업데이트 (논리 삭제) + @Transactional + public void delete(String email, Long dashboardId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + TeamDashboard dashboard = teamDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + verifyMemberIsAuthor(dashboard, member); + + dashboard.statusUpdate(); + } + + @Transactional + public void joinTeam(String email, Long dashboardId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + TeamDashboard dashboard = teamDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + dashboard.addMember(member); + } + + @Transactional + public void leaveTeam(String email, Long dashboardId) { + Member member = memberRepository.findByEmail(email).orElseThrow(MemberNotFoundException::new); + TeamDashboard dashboard = teamDashboardRepository.findById(dashboardId) + .orElseThrow(DashboardNotFoundException::new); + + dashboard.removeMember(member); + } + + public SearchMemberListResDto searchMembers(String query) { + List searchMembers = teamDashboardRepository.findForMembersByQuery(query); + + return SearchMemberListResDto.from(searchMembers); + } + + private void verifyMemberIsAuthor(TeamDashboard teamDashboard, Member member) { + if (!member.equals(teamDashboard.getMember())) { + throw new DashboardAccessDeniedException(); + } + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboard.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboard.java new file mode 100644 index 00000000..d5d76249 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboard.java @@ -0,0 +1,53 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TeamDashboard extends Dashboard { + + @OneToMany(mappedBy = "teamDashboard", cascade = CascadeType.ALL, orphanRemoval = true) + private List teamDashboardMemberMappings = new ArrayList<>(); + + @OneToMany(mappedBy = "teamDashboard", cascade = CascadeType.ALL) + private List documents = new ArrayList<>(); + + @Builder + private TeamDashboard(String title, String description, String dType, Member member) { + super(title, description, dType, member); + } + + public void update(String updateTitle, String updateDescription) { + super.update(updateTitle, updateDescription); + } + + public void statusUpdate() { + super.statusUpdate((super.getStatus() == Status.ACTIVE) ? Status.DELETED : Status.ACTIVE); + } + + public void addMember(Member member) { + teamDashboardMemberMappings.add(TeamDashboardMemberMapping.builder() + .teamDashboard(this) + .member(member) + .build()); + } + + public void removeMember(Member member) { + teamDashboardMemberMappings.removeIf(mapping -> mapping.getMember().equals(member)); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboardMemberMapping.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboardMemberMapping.java new file mode 100644 index 00000000..43053286 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboardMemberMapping.java @@ -0,0 +1,32 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TeamDashboardMemberMapping extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_dashboard_id", nullable = false) + private TeamDashboard teamDashboard; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Builder + private TeamDashboardMemberMapping(TeamDashboard teamDashboard, Member member) { + this.teamDashboard = teamDashboard; + this.member = member; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/repository/TeamDashboardRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/repository/TeamDashboardRepository.java new file mode 100644 index 00000000..406befeb --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/repository/TeamDashboardRepository.java @@ -0,0 +1,8 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository.DashboardCustomRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; + +public interface TeamDashboardRepository extends JpaRepository, DashboardCustomRepository { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/DocumentController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/DocumentController.java new file mode 100644 index 00000000..5c205ea6 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/DocumentController.java @@ -0,0 +1,52 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application.DocumentService; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/documents") +public class DocumentController { + + private final DocumentService documentService; + + @PostMapping("") + public RspTemplate save(@RequestBody DocumentInfoReqDto documentInfoReqDto) { + return new RspTemplate<>(HttpStatus.OK, "팀 문서 등록", documentService.save(documentInfoReqDto)); + } + + @PatchMapping("/{documentId}") + public RspTemplate update(@PathVariable(name = "documentId") Long documentId, + @RequestBody DocumentUpdateReqDto documentUpdateReqDto) { + return new RspTemplate<>(HttpStatus.OK, "팀 문서 수회", documentService.update(documentId, documentUpdateReqDto)); + } + + // 팀 문서 조회 + @GetMapping("") + public RspTemplate findForDocumentByTeamDashboardId( + @RequestParam(name = "teamDashboardId") Long teamDashboardId, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size) { + return new RspTemplate<>(HttpStatus.OK, + "블록 상태별 전체 조회", + documentService.findDocumentByTeamDashboardId(teamDashboardId, PageRequest.of(page, size))); + } + + // 팀 문서 삭제 + @DeleteMapping("{documentId}") + public RspTemplate delete(@PathVariable(name = "documentId") Long documentId) { + documentService.delete(documentId); + + return new RspTemplate<>(HttpStatus.OK, "팀 문서 삭제"); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/FileController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/FileController.java new file mode 100644 index 00000000..928f8970 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/FileController.java @@ -0,0 +1,54 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.FileInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application.FileService; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/files") +public class FileController { + + private final FileService fileService; + + @PostMapping("") + public RspTemplate save(@CurrentUserEmail String email, + @RequestBody FileInfoReqDto fileInfoReqDto) { + return new RspTemplate<>(HttpStatus.OK, "팀 파일 등록", fileService.save(email, fileInfoReqDto)); + } + + @PatchMapping("/{fileId}") + public RspTemplate update(@PathVariable(name = "fileId") Long fileId, + @RequestBody FileInfoReqDto fileInfoReqDto) { + return new RspTemplate<>(HttpStatus.OK, "팀 파일 수정", fileService.update(fileId, fileInfoReqDto)); + } + + @GetMapping("") + public RspTemplate findForFile(@RequestParam(name = "documentId") Long documentId, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size) { + return new RspTemplate<>(HttpStatus.OK, + "팀 문서 파일 조회", + fileService.findForFile(documentId, PageRequest.of(page, size))); + } + + @GetMapping("/{fileId}") + public RspTemplate findById(@PathVariable(name = "fileId") Long fileId) { + return new RspTemplate<>(HttpStatus.OK, "팀 파일 상세보기", fileService.findById(fileId)); + } + + @DeleteMapping("{fileId}") + public RspTemplate delete(@PathVariable(name = "fileId") Long blockId) { + fileService.delete(blockId); + + return new RspTemplate<>(HttpStatus.OK, "팀 파일 삭제"); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/DocumentInfoReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/DocumentInfoReqDto.java new file mode 100644 index 00000000..ded35d63 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/DocumentInfoReqDto.java @@ -0,0 +1,19 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Builder +public record DocumentInfoReqDto( + Long teamDashboardId, + String title +) { + public Document toEntity(TeamDashboard teamDashboard) { + return Document.builder() + .title(title) + .teamDashboard(teamDashboard) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/DocumentUpdateReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/DocumentUpdateReqDto.java new file mode 100644 index 00000000..52d880b3 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/DocumentUpdateReqDto.java @@ -0,0 +1,6 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request; + +public record DocumentUpdateReqDto ( + String title +){ +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/FileInfoReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/FileInfoReqDto.java new file mode 100644 index 00000000..04651cfa --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/request/FileInfoReqDto.java @@ -0,0 +1,20 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request; + +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; + +public record FileInfoReqDto( + Long documentId, + String email, + String title, + String content +) { + public File toEntity(String email, Document document) { + return File.builder() + .email(email) + .title(title) + .content(content) + .document(document) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/DocumentInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/DocumentInfoResDto.java new file mode 100644 index 00000000..0d8d3502 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/DocumentInfoResDto.java @@ -0,0 +1,17 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; + +@Builder +public record DocumentInfoResDto( + Long documentId, + String title +) { + public static DocumentInfoResDto from(Document document) { + return DocumentInfoResDto.builder() + .documentId(document.getId()) + .title(document.getTitle()) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/DocumentListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/DocumentListResDto.java new file mode 100644 index 00000000..74a68ecb --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/DocumentListResDto.java @@ -0,0 +1,19 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +import java.util.List; + +@Builder +public record DocumentListResDto( + List documentInfoResDtos, + PageInfoResDto pageInfoResDto +) { + public static DocumentListResDto of(List documentInfoResDtos, PageInfoResDto pageInfoResDto) { + return DocumentListResDto.builder() + .documentInfoResDtos(documentInfoResDtos) + .pageInfoResDto(pageInfoResDto) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/FileInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/FileInfoResDto.java new file mode 100644 index 00000000..f8972695 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/FileInfoResDto.java @@ -0,0 +1,21 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; + +@Builder +public record FileInfoResDto( + Long fileId, + String email, + String title, + String content +) { + public static FileInfoResDto from(File file) { + return FileInfoResDto.builder() + .fileId(file.getId()) + .email(file.getEmail()) + .title(file.getTitle()) + .content(file.getContent()) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/FileListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/FileListResDto.java new file mode 100644 index 00000000..9f14c89f --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/dto/response/FileListResDto.java @@ -0,0 +1,19 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +import java.util.List; + +@Builder +public record FileListResDto( + List fileInfoResDto, + PageInfoResDto pageInfoResDto +) { + public static FileListResDto of(List fileInfoResDto, PageInfoResDto pageInfoResDto) { + return FileListResDto.builder() + .fileInfoResDto(fileInfoResDto) + .pageInfoResDto(pageInfoResDto) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/DocumentService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/DocumentService.java new file mode 100644 index 00000000..3bc45ec1 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/DocumentService.java @@ -0,0 +1,70 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository.TeamDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.DocumentNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository.DocumentRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.TeamDashboardNotFoundException; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DocumentService { + + private final DocumentRepository documentRepository; + private final TeamDashboardRepository teamDashboardRepository; + + // 팀 문서 생성 + @Transactional + public DocumentInfoResDto save(DocumentInfoReqDto documentInfoReqDto) { + TeamDashboard teamDashboard = teamDashboardRepository.findById(documentInfoReqDto.teamDashboardId()) + .orElseThrow(TeamDashboardNotFoundException::new); + Document document = documentInfoReqDto.toEntity(teamDashboard); + + documentRepository.save(document); + + return DocumentInfoResDto.from(document); + } + + // 팀 문서 수정 + @Transactional + public DocumentInfoResDto update(Long documentId, DocumentUpdateReqDto documentUpdateReqDto) { + Document document = documentRepository.findById(documentId).orElseThrow(DocumentNotFoundException::new); + + document.update(documentUpdateReqDto.title()); + + return DocumentInfoResDto.from(document); + } + + // 팀 문서 조회 + public DocumentListResDto findDocumentByTeamDashboardId(Long teamDashboardId, Pageable pageable) { + Page documents = documentRepository.findByDocumentWithTeamDashboard(teamDashboardId, pageable); + + List documentInfoResDtoList = documents.stream() + .map(DocumentInfoResDto::from) + .toList(); + + return DocumentListResDto.of(documentInfoResDtoList, PageInfoResDto.from(documents)); + } + + // 팀 문서 삭제 + @Transactional + public void delete(Long documentId) { + Document document = documentRepository.findById(documentId).orElseThrow(DocumentNotFoundException::new); + + document.statusUpdate(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/FileService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/FileService.java new file mode 100644 index 00000000..a3c1586c --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/FileService.java @@ -0,0 +1,76 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.FileInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository.DocumentRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository.FileRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.DocumentNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.FileNotFoundException; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FileService { + + private final DocumentRepository documentRepository; + private final FileRepository fileRepository; + + // 팀 파일 생성 + @Transactional + public FileInfoResDto save(String email, FileInfoReqDto fileInfoReqDto) { + Document document = documentRepository.findById(fileInfoReqDto.documentId()) + .orElseThrow(DocumentNotFoundException::new); + File file = fileInfoReqDto.toEntity(email, document); + + fileRepository.save(file); + + return FileInfoResDto.from(file); + } + + // 팀 파일 수정 + @Transactional + public FileInfoResDto update(Long fileID, FileInfoReqDto fileInfoReqDto) { + File file = fileRepository.findById(fileID).orElseThrow(FileNotFoundException::new); + + file.update(fileInfoReqDto.title(), fileInfoReqDto.content()); + + return FileInfoResDto.from(file); + } + + // 팀 파일 리스트 조회 + public FileListResDto findForFile(Long documentId, Pageable pageable) { + Page files = fileRepository.findByFilesWithDocumentId(documentId, pageable); + + List fileInfoResDtoList = files.stream() + .map(FileInfoResDto::from) + .toList(); + + return FileListResDto.of(fileInfoResDtoList, PageInfoResDto.from(files)); + } + + // 팀 파일 상세보기 + public FileInfoResDto findById(Long fileId) { + File file = fileRepository.findById(fileId).orElseThrow(FileNotFoundException::new); + + return FileInfoResDto.from(file); + } + + // 팀 파일 삭제 + @Transactional + public void delete(Long fileId) { + File file = fileRepository.findById(fileId).orElseThrow(FileNotFoundException::new); + + file.statusUpdate(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/Document.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/Document.java new file mode 100644 index 00000000..3fd7fdab --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/Document.java @@ -0,0 +1,52 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Document extends BaseEntity { + + @Enumerated(value = EnumType.STRING) + private Status status; + + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "teamDashboard_id") + private TeamDashboard teamDashboard; + + @OneToMany(mappedBy = "document", cascade = CascadeType.ALL) + private List files = new ArrayList<>(); + + @Builder + private Document(String title, TeamDashboard teamDashboard) { + this.status = Status.ACTIVE; + this.title = title; + this.teamDashboard = teamDashboard; + } + + public void update(String updateTitle) { + if (isUpdateRequired(updateTitle)) { + this.title = updateTitle; + } + } + + private boolean isUpdateRequired(String updateTitle) { + return !this.title.equals(updateTitle); + } + + public void statusUpdate() { + this.status = (this.status == Status.ACTIVE) ? Status.DELETED : Status.ACTIVE; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/File.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/File.java new file mode 100644 index 00000000..a6acba7f --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/File.java @@ -0,0 +1,53 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class File extends BaseEntity { + + @Enumerated(value = EnumType.STRING) + private Status status; + + private String email; + + private String title; + + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "document_id") + private Document document; + + @Builder + private File(String email, String title, String content, Document document) { + this.status = Status.ACTIVE; + this.email = email; + this.title = title; + this.content = content; + this.document = document; + } + + public void update(String updateTitle, String updateContent) { + if (isUpdateRequired(updateTitle, updateContent)) { + this.title = updateTitle; + this.content = updateContent; + } + } + + private boolean isUpdateRequired(String updateTitle, String updateContent) { + return !this.title.equals(updateTitle) || + !this.content.equals(updateContent); + } + + public void statusUpdate() { + this.status = (this.status == Status.ACTIVE) ? Status.DELETED : Status.ACTIVE; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentCustomRepository.java new file mode 100644 index 00000000..e86a58a3 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentCustomRepository.java @@ -0,0 +1,9 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; + +public interface DocumentCustomRepository { + Page findByDocumentWithTeamDashboard(Long documentId, Pageable pageable); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentCustomRepositoryImpl.java new file mode 100644 index 00000000..26ecad85 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentCustomRepositoryImpl.java @@ -0,0 +1,46 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import static shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.QDocument.document; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +@Repository +@Transactional(readOnly = true) +public class DocumentCustomRepositoryImpl implements DocumentCustomRepository { + + private final JPAQueryFactory queryFactory; + + public DocumentCustomRepositoryImpl(JPAQueryFactory queryFactory) { + this.queryFactory = queryFactory; + } + + @Override + public Page findByDocumentWithTeamDashboard(Long teamDashboardId, Pageable pageable) { + long total = queryFactory + .selectFrom(document) + .where(document.teamDashboard.id.eq(teamDashboardId) + .and(document.status.eq(Status.ACTIVE))) + .fetchCount(); + + List documents = queryFactory + .selectFrom(document) + .where(document.teamDashboard.id.eq(teamDashboardId) + .and(document.status.eq(Status.ACTIVE))) + .orderBy(document.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(documents, pageable, total); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentRepository.java new file mode 100644 index 00000000..d8f3e226 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentRepository.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; + +public interface DocumentRepository extends JpaRepository, DocumentCustomRepository { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileCustomRepository.java new file mode 100644 index 00000000..185ae2bb --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileCustomRepository.java @@ -0,0 +1,10 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; + +public interface FileCustomRepository { + Page findByFilesWithDocumentId(Long documentId, Pageable pageable); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileCustomRepositoryImpl.java new file mode 100644 index 00000000..a3067b6c --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileCustomRepositoryImpl.java @@ -0,0 +1,46 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import static shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.QFile.file; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +@Repository +@Transactional(readOnly = true) +public class FileCustomRepositoryImpl implements FileCustomRepository { + + private final JPAQueryFactory queryFactory; + + public FileCustomRepositoryImpl(JPAQueryFactory queryFactory) { + this.queryFactory = queryFactory; + } + + @Override + public Page findByFilesWithDocumentId(Long documentId, Pageable pageable) { + long total = queryFactory + .selectFrom(file) + .where(file.document.id.eq(documentId) + .and(file.status.eq(Status.ACTIVE))) + .fetchCount(); + + List files = queryFactory + .selectFrom(file) + .where(file.document.id.eq(documentId) + .and(file.status.eq(Status.ACTIVE))) + .orderBy(file.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(files, pageable, total); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileRepository.java new file mode 100644 index 00000000..aff17d41 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileRepository.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; + +public interface FileRepository extends JpaRepository, FileCustomRepository { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/DocumentNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/DocumentNotFoundException.java new file mode 100644 index 00000000..51cc9426 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/DocumentNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class DocumentNotFoundException extends NotFoundGroupException { + public DocumentNotFoundException(String message) { + super(message); + } + + public DocumentNotFoundException() { + this("존재하지 않는 팀 문서 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/FileNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/FileNotFoundException.java new file mode 100644 index 00000000..da2a1c69 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/FileNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class FileNotFoundException extends NotFoundGroupException { + public FileNotFoundException(String message) { + super(message); + } + + public FileNotFoundException() { + this("존재하지 않는 팀 파일 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/TeamDashboardNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/TeamDashboardNotFoundException.java new file mode 100644 index 00000000..84482462 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/exception/TeamDashboardNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class TeamDashboardNotFoundException extends NotFoundGroupException { + public TeamDashboardNotFoundException(String message) { + super(message); + } + + public TeamDashboardNotFoundException() { + this("존재하지 않는 팀 대시보드 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/annotation/CurrentUserEmail.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/annotation/CurrentUserEmail.java new file mode 100644 index 00000000..af81fefe --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/annotation/CurrentUserEmail.java @@ -0,0 +1,12 @@ +package shop.kkeujeok.kkeujeokbackend.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface CurrentUserEmail { +} + diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/annotationresolver/CurrentUserEmailArgumentResolver.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/annotationresolver/CurrentUserEmailArgumentResolver.java new file mode 100644 index 00000000..59e7ef6c --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/annotationresolver/CurrentUserEmailArgumentResolver.java @@ -0,0 +1,44 @@ +package shop.kkeujeok.kkeujeokbackend.global.annotationresolver; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; + +@Component +public class CurrentUserEmailArgumentResolver implements HandlerMethodArgumentResolver { + + private final TokenProvider tokenProvider; + + public CurrentUserEmailArgumentResolver(TokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterAnnotation(CurrentUserEmail.class) != null; + //@CurrentUserEmail 어노테이션으로 주석되어 있는지 확인하는 로직. + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + String token = request.getHeader("Authorization"); + + if (token != null && token.startsWith("Bearer ")) { + token = token.substring(7); // "Bearer " 이후의 토큰 부분만 추출하고 + TokenReqDto tokenReqDto = new TokenReqDto(token); + + return tokenProvider.getUserEmailFromToken(tokenReqDto); // 만들어둔 메서드로 email 값 반환 + } + + return null; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/AnnotationWebConfig.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/AnnotationWebConfig.java new file mode 100644 index 00000000..b1e6e3b1 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/AnnotationWebConfig.java @@ -0,0 +1,22 @@ +package shop.kkeujeok.kkeujeokbackend.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class AnnotationWebConfig implements WebMvcConfigurer { + + private final CurrentUserEmailArgumentResolver currentUserEmailArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentUserEmailArgumentResolver); + } +} + diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/AppConfig.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/AppConfig.java new file mode 100644 index 00000000..ef62877b --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/AppConfig.java @@ -0,0 +1,23 @@ +package shop.kkeujeok.kkeujeokbackend.global.config; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Random; + +@Configuration +public class AppConfig { + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + + @Bean + public Random random() { + return new Random(); + } +} + diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/FilterWebConfig.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/FilterWebConfig.java new file mode 100644 index 00000000..0f81c678 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/FilterWebConfig.java @@ -0,0 +1,34 @@ +package shop.kkeujeok.kkeujeokbackend.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import shop.kkeujeok.kkeujeokbackend.global.filter.LogFilter; +import shop.kkeujeok.kkeujeokbackend.global.filter.LoginCheckFilter; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; + +@Configuration +@RequiredArgsConstructor +public class FilterWebConfig { + + private final TokenProvider tokenProvider; + + @Bean + public FilterRegistrationBean logFilter() { + FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean<>(); + filterRegistrationBean.setFilter(new LogFilter()); // 여기서 만든 필터 클래스 등록 + filterRegistrationBean.setOrder(1); + filterRegistrationBean.addUrlPatterns("/*"); + return filterRegistrationBean; + } + + @Bean + public FilterRegistrationBean loginCheckFilter() { + FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean<>(); + filterRegistrationBean.setFilter(new LoginCheckFilter(tokenProvider)); // JWT 토큰 유효성 검사를 위한 필터 클래스 등록 + filterRegistrationBean.setOrder(2); // 1번인 로그필터 다음으로 수행 + filterRegistrationBean.addUrlPatterns("/*"); + return filterRegistrationBean; + } +} \ No newline at end of file diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/JpaAuditingConfig.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/JpaAuditingConfig.java new file mode 100644 index 00000000..885cb555 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package shop.kkeujeok.kkeujeokbackend.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/QuerydslConfig.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/QuerydslConfig.java new file mode 100644 index 00000000..82e3d1ae --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package shop.kkeujeok.kkeujeokbackend.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/SchedulerConfig.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/SchedulerConfig.java new file mode 100644 index 00000000..11df3be1 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/config/SchedulerConfig.java @@ -0,0 +1,9 @@ +package shop.kkeujeok.kkeujeokbackend.global.config; + +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.stereotype.Component; + +@Component +@EnableScheduling +public class SchedulerConfig { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/dto/PageInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/dto/PageInfoResDto.java new file mode 100644 index 00000000..8fa35f2e --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/dto/PageInfoResDto.java @@ -0,0 +1,19 @@ +package shop.kkeujeok.kkeujeokbackend.global.dto; + +import lombok.Builder; +import org.springframework.data.domain.Page; + +@Builder +public record PageInfoResDto( + int currentPage, + int totalPages, + long totalItems +) { + public static PageInfoResDto from(Page entityPage) { + return PageInfoResDto.builder() + .currentPage(entityPage.getNumber()) + .totalPages(entityPage.getTotalPages()) + .totalItems(entityPage.getTotalElements()) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/entity/BaseEntity.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/entity/BaseEntity.java new file mode 100644 index 00000000..50068ca6 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/entity/BaseEntity.java @@ -0,0 +1,31 @@ +package shop.kkeujeok.kkeujeokbackend.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/entity/Status.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/entity/Status.java new file mode 100644 index 00000000..728b75a1 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/entity/Status.java @@ -0,0 +1,10 @@ +package shop.kkeujeok.kkeujeokbackend.global.entity; + +public enum Status { + ACTIVE("활성화"), + UN_ACTIVE("비활성화"), + DELETED("삭제"); + + Status(String description) { + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/ControllerAdvice.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/ControllerAdvice.java new file mode 100644 index 00000000..68d13656 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/ControllerAdvice.java @@ -0,0 +1,64 @@ +package shop.kkeujeok.kkeujeokbackend.global.error; + +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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; +import shop.kkeujeok.kkeujeokbackend.global.error.dto.ErrorResponse; +import shop.kkeujeok.kkeujeokbackend.global.error.exception.AccessDeniedGroupException; +import shop.kkeujeok.kkeujeokbackend.global.error.exception.AuthGroupException; +import shop.kkeujeok.kkeujeokbackend.global.error.exception.InvalidGroupException; +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +@Slf4j +@RestControllerAdvice +public class ControllerAdvice { + + // custom error + @ExceptionHandler({InvalidGroupException.class}) + public ResponseEntity handleInvalidData(RuntimeException e) { + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), e.getMessage()); + log.error(e.getMessage()); + + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler({AuthGroupException.class}) + public ResponseEntity handleAuthDate(RuntimeException e) { + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), e.getMessage()); + log.error(e.getMessage()); + + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler({NotFoundGroupException.class}) + public ResponseEntity handleNotFoundDate(RuntimeException e) { + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.NOT_FOUND.value(), e.getMessage()); + log.error(e.getMessage()); + + return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler({AccessDeniedGroupException.class}) + public ResponseEntity handleAccessDeniedDate(RuntimeException e) { + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.FORBIDDEN.value(), e.getMessage()); + log.error(e.getMessage()); + + return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN); + } + + // Validation 관련 예외 처리 + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity handleMethodArgumentNotValidException(final MethodArgumentNotValidException e) { + FieldError fieldError = Objects.requireNonNull(e.getFieldError()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), + String.format("%s. (%s)", fieldError.getDefaultMessage(), fieldError.getField())); + + log.error("Validation error for field {}: {}", fieldError.getField(), fieldError.getDefaultMessage()); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/dto/ErrorResponse.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/dto/ErrorResponse.java new file mode 100644 index 00000000..ffa1e53d --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/dto/ErrorResponse.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.global.error.dto; + +public record ErrorResponse( + int statusCode, + String message +) { +} \ No newline at end of file diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/exception/AccessDeniedGroupException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/exception/AccessDeniedGroupException.java new file mode 100644 index 00000000..d5fced59 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/exception/AccessDeniedGroupException.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.global.error.exception; + +public abstract class AccessDeniedGroupException extends RuntimeException{ + public AccessDeniedGroupException(String message) { + super(message); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/exception/AuthGroupException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/exception/AuthGroupException.java new file mode 100644 index 00000000..85997c04 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/exception/AuthGroupException.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.global.error.exception; + +public abstract class AuthGroupException extends RuntimeException{ + public AuthGroupException(String message) { + super(message); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/exception/InvalidGroupException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/exception/InvalidGroupException.java new file mode 100644 index 00000000..97b87007 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/exception/InvalidGroupException.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.global.error.exception; + +public abstract class InvalidGroupException extends RuntimeException{ + public InvalidGroupException(String message) { + super(message); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/exception/NotFoundGroupException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/exception/NotFoundGroupException.java new file mode 100644 index 00000000..dade31e6 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/error/exception/NotFoundGroupException.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.global.error.exception; + +public abstract class NotFoundGroupException extends RuntimeException{ + public NotFoundGroupException(String message) { + super(message); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/LogFilter.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/LogFilter.java new file mode 100644 index 00000000..04f613bd --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/LogFilter.java @@ -0,0 +1,36 @@ +package shop.kkeujeok.kkeujeokbackend.global.filter; + +import java.io.IOException; +import java.util.UUID; + + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.filter.GenericFilterBean; + +@Slf4j +public class LogFilter extends GenericFilterBean { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + log.info("log filter doFilter"); + + HttpServletRequest httpRequest = (HttpServletRequest) request; + String requestURI = httpRequest.getRequestURI(); + + String uuid = UUID.randomUUID().toString(); + + try { + log.info("REQUEST [{}][{}]", uuid, requestURI); + chain.doFilter(request, response); + // chain이 없으면 여기서 끝난다. 즉, 로그만 띄우고 컨트롤러까지 가지 않아서 백지만 나온다. + // chain doFilter로 다시 호출해주면 controller로 넘어가서 정상적으로 페이지를 띄운다. + } catch (Exception e) { + throw e; + } finally { + log.info("REQUEST [{}][{}]", uuid, requestURI); + } + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/LoginCheckFilter.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/LoginCheckFilter.java new file mode 100644 index 00000000..eda6e8fd --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/LoginCheckFilter.java @@ -0,0 +1,69 @@ +package shop.kkeujeok.kkeujeokbackend.global.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.PatternMatchUtils; +import org.springframework.web.filter.GenericFilterBean; +import shop.kkeujeok.kkeujeokbackend.global.filter.exceptiton.AuthenticationException; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class LoginCheckFilter extends GenericFilterBean { + + private static final String[] whiteList = { + "*", // 일단 다 열어둠 +// "/", +// "/api/oauth2/callback/**", +// "/api/*/token", +// "/api/token/access", + }; + + private final TokenProvider tokenProvider; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + String requestURI = httpRequest.getRequestURI(); + try { + log.info("인증 체크 필터 시작{}", requestURI); + if (!isLoginCheckPath(requestURI)) { + log.info("인증 체크 로직 실행{}", requestURI); + String token = resolveToken(httpRequest); + if (token == null || !tokenProvider.validateToken(token)) { + log.info("미인증 사용자 요청 {}", requestURI); + httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); + return; + } + // 토큰이 유효한 경우 사용자 정보를 로그로 출력 + } + chain.doFilter(request, response); + } catch (AuthenticationException e) { + throw e; + } finally { + log.info("인증 체크 필터 종료{}", requestURI); + } + } + + private boolean isLoginCheckPath(String requestURI) { + return PatternMatchUtils.simpleMatch(whiteList, requestURI); // 화이트리스트에 있는 경로는 true 반환 + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/exceptiton/AuthenticationException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/exceptiton/AuthenticationException.java new file mode 100644 index 00000000..dcadc2f4 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/filter/exceptiton/AuthenticationException.java @@ -0,0 +1,14 @@ +package shop.kkeujeok.kkeujeokbackend.global.filter.exceptiton; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.AuthGroupException; + +public class AuthenticationException extends AuthGroupException { + + public AuthenticationException(String message) { + super(message); + } + + public AuthenticationException() { + this("인증에 실패했습니다. 자격 증명을 확인하고 다시 시도하십시오."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProvider.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProvider.java new file mode 100644 index 00000000..07843377 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProvider.java @@ -0,0 +1,130 @@ +package shop.kkeujeok.kkeujeokbackend.global.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.global.jwt.api.dto.TokenDto; + +import java.security.Key; +import java.util.Date; + +@Slf4j +@Getter +@Component +@NoArgsConstructor +public class TokenProvider { + + @Value("${token.expire.time.access}") + private String accessTokenExpireTime; + + @Value("${token.expire.time.refresh}") + private String refreshTokenExpireTime; + + @Value("${jwt.secret}") + private String secret; + + private Key key; + + public TokenProvider(String accessTokenExpireTime, String refreshTokenExpireTime, String secret, Key key) { + this.accessTokenExpireTime = accessTokenExpireTime; + this.refreshTokenExpireTime = refreshTokenExpireTime; + this.secret = secret; + this.key = key; + } + + @PostConstruct + public void init() { + byte[] keyBytes = hexStringToByteArray(secret); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String getUserEmailFromToken(TokenReqDto tokenReqDto) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(tokenReqDto.authCode()) + .getBody(); + return claims.getSubject(); // 토큰의 subject를 사용자 ID로 간주 + } + + public boolean validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + + return true; + } catch (UnsupportedJwtException | MalformedJwtException exception) { + log.error("JWT is not valid"); + } catch (SignatureException exception) { + log.error("JWT signature validation fails"); + } catch (ExpiredJwtException exception) { + log.error("JWT expired"); + } catch (IllegalArgumentException exception) { + log.error("JWT is null or empty or only whitespace"); + } catch (Exception exception) { + log.error("JWT validation fails", exception); + } + + return false; + } + + public TokenDto generateToken(String email) { + String accessToken = generateAccessToken(email); + String refreshToken = generateRefreshToken(); + + return TokenDto.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + public TokenDto generateAccessTokenByRefreshToken(String email, String refreshToken) { + String accessToken = generateAccessToken(email); + + return TokenDto.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + public String generateAccessToken(String email) { + Date date = new Date(); + Date accessExpiryDate = new Date(date.getTime() + Long.parseLong(accessTokenExpireTime)); + + return Jwts.builder() + .setSubject(email) + .setIssuedAt(date) + .setExpiration(accessExpiryDate) + .signWith(key, SignatureAlgorithm.HS512) + .compact(); + } + + public String generateRefreshToken() { + Date date = new Date(); + Date refreshExpiryDate = new Date(date.getTime() + Long.parseLong(refreshTokenExpireTime)); + + return Jwts.builder() + .setExpiration(refreshExpiryDate) + .signWith(key, SignatureAlgorithm.HS512) + .compact(); + } + + private byte[] hexStringToByteArray(String secret) { + int len = secret.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(secret.charAt(i), 16) << 4) + + Character.digit(secret.charAt(i + 1), 16)); + } + return data; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/api/dto/TokenDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/api/dto/TokenDto.java new file mode 100644 index 00000000..616698f2 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/api/dto/TokenDto.java @@ -0,0 +1,11 @@ +package shop.kkeujeok.kkeujeokbackend.global.jwt.api.dto; + +import lombok.Builder; + +@Builder +public record TokenDto( + String accessToken, + String refreshToken +) { + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/Token.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/Token.java new file mode 100644 index 00000000..a9b43785 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/Token.java @@ -0,0 +1,37 @@ +package shop.kkeujeok.kkeujeokbackend.global.jwt.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Token { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "tokenId") + private Long tokenId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "memberId") + private Member member; + + private String refreshToken; + + @Builder + public Token(Member member, String refreshToken) { + this.member = member; + this.refreshToken = refreshToken; + } + + public void refreshTokenUpdate(String refreshToken) { + if (!this.refreshToken.equals(refreshToken)) { + this.refreshToken = refreshToken; + } + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/repository/TokenRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/repository/TokenRepository.java new file mode 100644 index 00000000..34f33110 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/repository/TokenRepository.java @@ -0,0 +1,15 @@ +package shop.kkeujeok.kkeujeokbackend.global.jwt.domain.repository; + + +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.global.jwt.domain.Token; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +import java.util.Optional; + +public interface TokenRepository extends JpaRepository { + boolean existsByMember(Member member); + Optional findByMember(Member member); + boolean existsByRefreshToken(String refreshToken); + Optional findByRefreshToken(String refreshToken); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthService.java new file mode 100644 index 00000000..e66e9235 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthService.java @@ -0,0 +1,98 @@ +package shop.kkeujeok.kkeujeokbackend.global.oauth; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; +import shop.kkeujeok.kkeujeokbackend.auth.application.AuthService; +import shop.kkeujeok.kkeujeokbackend.global.oauth.exception.OAuthException; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +@Service +@Transactional(readOnly = true) +public class GoogleAuthService implements AuthService { + + private static final String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; + private static final String JWT_DELIMITER = "\\."; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + @Value("${google.client.id}") + private String google_client_id; + @Value("${google.client.secret}") + private String google_client_secret; + @Value("${google.redirect.uri}") + private String google_redirect_uri; + + public GoogleAuthService(ObjectMapper objectMapper, RestTemplate restTemplate) { + this.objectMapper = objectMapper; + this.restTemplate = restTemplate; + } + + @Override + public JsonNode getIdToken(String code) { + Map params = Map.of( + "code", code, + "scope", "https://www.googleapis.com/auth/userinfo.profile " + + "https://www.googleapis.com/auth/userinfo.email", + "client_id", google_client_id, + "client_secret", google_client_secret, + "redirect_uri", google_redirect_uri, + "grant_type", "authorization_code" + ); + + ResponseEntity responseEntity = restTemplate.postForEntity(GOOGLE_TOKEN_URL, params, String.class); + + return parseGoogleIdToken(responseEntity); + } + + @Override + public String getProvider() { + return String.valueOf(SocialType.GOOGLE).toLowerCase(); + } + + @Transactional + @Override + public UserInfo getUserInfo(String idToken) { + String decodePayload = getDecodePayload(idToken); + + try { + return objectMapper.readValue(decodePayload, UserInfo.class); + } catch (JsonProcessingException e) { + throw new OAuthException("id 토큰을 읽을 수 없습니다."); + } + } + + private JsonNode parseGoogleIdToken(ResponseEntity responseEntity) { + if (responseEntity.getStatusCode().is2xxSuccessful()) { + String responseBody = responseEntity.getBody(); + try { + JsonNode jsonNode = objectMapper.readTree(responseBody); + return jsonNode.get("id_token"); + } catch (Exception e) { + throw new RuntimeException("ID 토큰을 파싱하는데 실패했습니다.", e); + } + } + + throw new RuntimeException("구글 엑세스 토큰을 가져오는데 실패했습니다."); + } + + private String getDecodePayload(String idToken) { + String payload = getPayload(idToken); + + return new String(Base64.getUrlDecoder().decode(payload), StandardCharsets.UTF_8); + } + + private String getPayload(String idToken) { + return idToken.split(JWT_DELIMITER)[1]; + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthService.java new file mode 100644 index 00000000..77c47222 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthService.java @@ -0,0 +1,103 @@ +package shop.kkeujeok.kkeujeokbackend.global.oauth; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; +import shop.kkeujeok.kkeujeokbackend.auth.application.AuthService; +import shop.kkeujeok.kkeujeokbackend.global.oauth.exception.OAuthException; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +@Slf4j +@Service +@Transactional(readOnly = true) +public class KakaoAuthService implements AuthService { + + private static final String KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token"; + private static final String JWT_DELIMITER = "\\."; + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate; + @Value("${oauth.kakao.rest-api-key}") + private String restApiKey; + @Value("${oauth.kakao.redirect-url}") + private String redirectUri; + + public KakaoAuthService(ObjectMapper objectMapper, RestTemplate restTemplate) { + this.objectMapper = objectMapper; + this.restTemplate = restTemplate; + } + + @Override + public JsonNode getIdToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8"); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", restApiKey); + params.add("redirect_uri", redirectUri); + params.add("code", code); + + HttpEntity> kakaoTokenRequest = new HttpEntity<>(params, headers); + + ResponseEntity response = restTemplate.exchange( + KAKAO_TOKEN_URL, + HttpMethod.POST, + kakaoTokenRequest, + String.class + ); + + if (response.getStatusCode().is2xxSuccessful()) { + String responseBody = response.getBody(); + try { + JsonNode jsonNode = objectMapper.readTree(responseBody); + return jsonNode.get("id_token"); + } catch (Exception e) { + throw new RuntimeException("ID 토큰을 파싱하는데 실패했습니다.", e); + } + } + throw new RuntimeException("구글 엑세스 토큰을 가져오는데 실패했습니다."); + } + + @Override + public String getProvider() { + return String.valueOf(SocialType.KAKAO).toLowerCase(); + } + + @Transactional + @Override + public UserInfo getUserInfo(String idToken) { + String decodePayload = getDecodePayload(idToken); + + try { + return objectMapper.readValue(decodePayload, UserInfo.class); + } catch (JsonProcessingException e) { + throw new OAuthException("id 토큰을 읽을 수 없습니다."); + } + } + + private String getDecodePayload(String idToken) { + String payload = getPayload(idToken); + + return new String(Base64.getUrlDecoder().decode(payload), StandardCharsets.UTF_8); + } + + private String getPayload(String idToken) { + return idToken.split(JWT_DELIMITER)[1]; + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/exception/OAuthException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/exception/OAuthException.java new file mode 100644 index 00000000..c54f5be5 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/oauth/exception/OAuthException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.global.oauth.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.AuthGroupException; + +public class OAuthException extends AuthGroupException { + public OAuthException(String message) { + super(message); + } + + public OAuthException() { + this("OAuth 서버와의 통신 과정에서 문제가 발생했습니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/global/template/RspTemplate.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/template/RspTemplate.java new file mode 100644 index 00000000..6310ff9e --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/global/template/RspTemplate.java @@ -0,0 +1,23 @@ +package shop.kkeujeok.kkeujeokbackend.global.template; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +// 응답 템플릿 +@Getter +public class RspTemplate { + int statusCode; + String message; + T data; + + public RspTemplate(HttpStatus httpStatus, String message, T data) { + this.statusCode = httpStatus.value(); + this.message = message; + this.data = data; + } + + public RspTemplate(HttpStatus httpStatus, String message) { + this.statusCode = httpStatus.value(); + this.message = message; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/api/MemberController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/api/MemberController.java new file mode 100644 index 00000000..dfeeca74 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/api/MemberController.java @@ -0,0 +1,41 @@ +package shop.kkeujeok.kkeujeokbackend.member.api; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.request.MyPageUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.MyPageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.TeamDashboardsAndChallengesResDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.application.MyPageService; + +import java.util.Set; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/members") +public class MemberController { + + private final MyPageService myPageService; + + @GetMapping("/mypage") + public RspTemplate myProfileInfo(@CurrentUserEmail String email) { + MyPageInfoResDto memberResDto = myPageService.findMyProfileByEmail(email); + return new RspTemplate<>(HttpStatus.OK, "내 프로필 정보", memberResDto); + } + + @PatchMapping("/mypage") + public RspTemplate update(@CurrentUserEmail String email, + @RequestBody MyPageUpdateReqDto myPageUpdateReqDto) { + return new RspTemplate<>(HttpStatus.OK, "내 프로필 정보 수정", myPageService.update(email, myPageUpdateReqDto)); + } + + @GetMapping("/mypage/dashboard-challenges") + public RspTemplate getTeamDashboardsAndChallenges(@CurrentUserEmail String email, + Pageable pageable) { + TeamDashboardsAndChallengesResDto response = myPageService.findTeamDashboardsAndChallenges(email, pageable); + return new RspTemplate<>(HttpStatus.OK, "팀 대시보드와 챌린지 정보 조회", response); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/Member.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/Member.java new file mode 100644 index 00000000..abbb70c5 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/Member.java @@ -0,0 +1,85 @@ +package shop.kkeujeok.kkeujeokbackend.member.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; + +@Entity +@Getter +@NoArgsConstructor +public class Member extends BaseEntity { + + @Enumerated(EnumType.STRING) + private Status status; + + private boolean firstLogin; + + @Enumerated(EnumType.STRING) + private Role role; + + private String email; + + private String name; + + private String picture; + + @Enumerated(value = EnumType.STRING) + private SocialType socialType; + + private String nickname; + + private String introduction; + + @OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + private List challenges = new ArrayList<>(); + + private String tag; + + @OneToMany(mappedBy = "receiver", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + private List notifications = new ArrayList<>(); + + @Builder + private Member(Status status, Role role, + String email, String name, + String picture, + SocialType socialType, + boolean firstLogin, + String nickname, + String introduction, + String tag) { + this.status = status; + this.role = role; + this.email = email; + this.name = name; + this.picture = picture; + this.socialType = socialType; + this.firstLogin = firstLogin; + this.nickname = nickname; + this.introduction = introduction; + this.tag = tag; + } + + public void update(String nickname, String introduction) { + if (isUpdateRequired(nickname, introduction)) { + this.nickname = nickname; + this.introduction = introduction; + } + } + + private boolean isUpdateRequired(String updateNickname, String updateIntroduction) { + return !this.nickname.equals(updateNickname) || + !this.introduction.equals(updateIntroduction); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/Role.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/Role.java new file mode 100644 index 00000000..82f4afb5 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/Role.java @@ -0,0 +1,5 @@ +package shop.kkeujeok.kkeujeokbackend.member.domain; + +public enum Role { + ROLE_ADMIN, ROLE_USER +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/SocialType.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/SocialType.java new file mode 100644 index 00000000..6418f676 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/SocialType.java @@ -0,0 +1,5 @@ +package shop.kkeujeok.kkeujeokbackend.member.domain; + +public enum SocialType { + GOOGLE, KAKAO +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberCustomRepository.java new file mode 100644 index 00000000..40b85f74 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberCustomRepository.java @@ -0,0 +1,5 @@ +package shop.kkeujeok.kkeujeokbackend.member.domain.repository; + +public interface MemberCustomRepository { + boolean existsByNicknameAndTag(String nickname, String tag); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberCustomRepositoryImpl.java new file mode 100644 index 00000000..f48244e8 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberCustomRepositoryImpl.java @@ -0,0 +1,21 @@ +package shop.kkeujeok.kkeujeokbackend.member.domain.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.member.domain.QMember; + +@RequiredArgsConstructor +public class MemberCustomRepositoryImpl implements MemberCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public boolean existsByNicknameAndTag(String nickname, String tag) { + QMember member = QMember.member; + + return queryFactory.selectFrom(member) + .where(member.nickname.eq(nickname) + .and(member.tag.eq(tag))) + .fetchFirst() != null; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberRepository.java new file mode 100644 index 00000000..4ed8b605 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberRepository.java @@ -0,0 +1,15 @@ +package shop.kkeujeok.kkeujeokbackend.member.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +import java.util.Optional; + +public interface MemberRepository extends + JpaRepository, + JpaSpecificationExecutor, + MemberCustomRepository { + Optional findByEmail(String email); + boolean existsByNickname(String nickname); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/exception/MemberNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/exception/MemberNotFoundException.java new file mode 100644 index 00000000..6afcfa20 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/exception/MemberNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.member.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class MemberNotFoundException extends NotFoundGroupException { + public MemberNotFoundException(String message) { + super(message); + } + + public MemberNotFoundException() { + this("존재하지 않는 회원입니다"); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/request/MyPageUpdateReqDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/request/MyPageUpdateReqDto.java new file mode 100644 index 00000000..87d28d24 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/request/MyPageUpdateReqDto.java @@ -0,0 +1,7 @@ +package shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.request; + +public record MyPageUpdateReqDto( + String nickname, + String introduction +) { +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/response/MyPageInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/response/MyPageInfoResDto.java new file mode 100644 index 00000000..66923bde --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/response/MyPageInfoResDto.java @@ -0,0 +1,27 @@ +package shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +@Builder +public record MyPageInfoResDto( + String picture, + String email, + String name, + String nickName, + SocialType socialType, + String introduction + +) { + public static MyPageInfoResDto From(Member member) { + return MyPageInfoResDto.builder() + .picture(member.getPicture()) + .email(member.getEmail()) + .name(member.getName()) + .nickName(member.getNickname()) + .socialType(member.getSocialType()) + .introduction(member.getIntroduction()) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/response/TeamDashboardsAndChallengesResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/response/TeamDashboardsAndChallengesResDto.java new file mode 100644 index 00000000..093700d0 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/api/dto/response/TeamDashboardsAndChallengesResDto.java @@ -0,0 +1,16 @@ +package shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response; + +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; + +public record TeamDashboardsAndChallengesResDto( + TeamDashboardListResDto teamDashboardList, + ChallengeListResDto challengeList +) { + public static TeamDashboardsAndChallengesResDto of(TeamDashboardListResDto teamDashboardList, ChallengeListResDto challengeList) { + return new TeamDashboardsAndChallengesResDto( + teamDashboardList, + challengeList + ); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/application/MyPageService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/application/MyPageService.java new file mode 100644 index 00000000..3e670846 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/application/MyPageService.java @@ -0,0 +1,69 @@ +package shop.kkeujeok.kkeujeokbackend.member.mypage.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.auth.exception.EmailNotFoundException; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.application.ChallengeService; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.application.TeamDashboardService; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.request.MyPageUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.MyPageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.TeamDashboardsAndChallengesResDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.exception.ExistsNicknameException; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MyPageService { + + private final MemberRepository memberRepository; + private final TeamDashboardService teamDashboardService; + private final ChallengeService challengeService; + + // 프로필 정보 조회 + public MyPageInfoResDto findMyProfileByEmail(String email) { + Member member = memberRepository.findByEmail(email).orElseThrow(); + + return MyPageInfoResDto.From(member); + } + + // 프로필 정보 수정 + @Transactional + public MyPageInfoResDto update(String email, MyPageUpdateReqDto myPageUpdateReqDto) { + Member member = memberRepository.findByEmail(email).orElseThrow(EmailNotFoundException::new); + + if (isNicknameChanged(member, myPageUpdateReqDto.nickname()) && isNicknameDuplicate(myPageUpdateReqDto.nickname())) { + throw new ExistsNicknameException(); + } + + member.update(myPageUpdateReqDto.nickname(), myPageUpdateReqDto.introduction()); + + return MyPageInfoResDto.From(member); + } + + // 팀 대시보드 & 챌린지 정보 조회 + @Transactional(readOnly = true) + public TeamDashboardsAndChallengesResDto findTeamDashboardsAndChallenges(String email, Pageable pageable) { + TeamDashboardListResDto teamDashboardListResDto = teamDashboardService.findForTeamDashboard(email, pageable); + ChallengeListResDto challengeListResDto = challengeService.findChallengeForMemberId(email, pageable); + + return TeamDashboardsAndChallengesResDto.of(teamDashboardListResDto, challengeListResDto); + } + + private boolean isNicknameChanged(Member member, String newNickname) { + return !normalizeNickname(member.getNickname()).equals(normalizeNickname(newNickname)); + } + + private boolean isNicknameDuplicate(String nickname) { + return memberRepository.existsByNickname(normalizeNickname(nickname)); + } + + private String normalizeNickname(String nickname) { + return nickname.replaceAll("\\s+", ""); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/exception/ExistsNicknameException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/exception/ExistsNicknameException.java new file mode 100644 index 00000000..7d40d16b --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/mypage/exception/ExistsNicknameException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.member.mypage.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.InvalidGroupException; + +public class ExistsNicknameException extends InvalidGroupException { + public ExistsNicknameException(String message) { + super(message); + } + + public ExistsNicknameException() { + this("이미 사용중인 닉네임 입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/nickname/application/NicknameService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/nickname/application/NicknameService.java new file mode 100644 index 00000000..2d06a00e --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/nickname/application/NicknameService.java @@ -0,0 +1,41 @@ +package shop.kkeujeok.kkeujeokbackend.member.nickname.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Random; + +@Service +@Transactional(readOnly = true) +public class NicknameService { + + private final List adjectives; + private final List nouns; + private final Random random; + + public NicknameService(@Qualifier("adjectives") List adjectives, + @Qualifier("nouns") List nouns, + Random random) { + this.adjectives = adjectives; + this.nouns = nouns; + this.random = random; + } + + public String getRandomNickname() { + return generateNickname(); + } + + private String generateNickname() { + String adjective = getRandomElement(adjectives); + String noun = getRandomElement(nouns); + + return adjective + noun; + } + + private String getRandomElement(List list) { + return list.get(random.nextInt(list.size())); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/nickname/config/NicknameConfig.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/nickname/config/NicknameConfig.java new file mode 100644 index 00000000..ad937d2b --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/nickname/config/NicknameConfig.java @@ -0,0 +1,31 @@ +package shop.kkeujeok.kkeujeokbackend.member.nickname.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.annotation.Qualifier; + +import java.util.List; + +@Configuration +public class NicknameConfig { + + @Bean + @Qualifier("adjectives") + public List adjectives() { + return List.of( + "귀여운", "행복한", "깜찍한", "명랑한", "재미있는", + "용감한", "사려깊은", "활기찬", "사랑스러운", "친절한", + "밝은", "기분좋은", "즐거운", "신나는", "멋진" + ); + } + + @Bean + @Qualifier("nouns") + public List nouns() { + return List.of( + "고양이", "강아지", "토끼", "곰", "여우", + "판다", "호랑이", "사자", "다람쥐", "고슴도치", + "햄스터", "펭귄", "수달", "부엉이", "돌고래" + ); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/member/tag/application/TagService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/tag/application/TagService.java new file mode 100644 index 00000000..8bde1513 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/member/tag/application/TagService.java @@ -0,0 +1,37 @@ +package shop.kkeujeok.kkeujeokbackend.member.tag.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +import java.util.Random; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TagService { + + private final Random random; + private final MemberRepository memberRepository; + + public String getRandomTag(String nickname) { + return generateUniqueTag(nickname); + } + + private String generateUniqueTag(String nickname) { + String tag = generateTag(); + + if (memberRepository.existsByNicknameAndTag(nickname, tag)) { + return generateUniqueTag(nickname); + } + + return tag; + } + + private String generateTag() { + int randomNumber = 1000 + random.nextInt(9000); + + return "#" + randomNumber; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/NotificationController.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/NotificationController.java new file mode 100644 index 00000000..5ca1fa70 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/NotificationController.java @@ -0,0 +1,44 @@ +package shop.kkeujeok.kkeujeokbackend.notification.api; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.template.RspTemplate; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationInfoResDto; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationListResDto; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping("/stream") + public SseEmitter streamNotifications(@CurrentUserEmail String email) { + return notificationService.createEmitter(email); + } + + @GetMapping + public RspTemplate findAllNotifications(@CurrentUserEmail String email, + @RequestParam(defaultValue = "0", name = "page") int page, + @RequestParam(defaultValue = "10", name = "size") int size) { + return new RspTemplate<>(HttpStatus.OK, "알림 조회 성공", + notificationService.findAllNotificationsFromMember(email, PageRequest.of(page, size))); + } + + @GetMapping("/{notificationId}") + public RspTemplate findNotificationById( + @PathVariable(name = "notificationId") Long notificationId) { + return new RspTemplate<>(HttpStatus.OK, "알림 상세 조회 성공", + notificationService.findByNotificationId(notificationId)); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/dto/response/NotificationInfoResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/dto/response/NotificationInfoResDto.java new file mode 100644 index 00000000..849013d2 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/dto/response/NotificationInfoResDto.java @@ -0,0 +1,20 @@ +package shop.kkeujeok.kkeujeokbackend.notification.api.dto.response; + +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; + +@Builder +public record NotificationInfoResDto( + Long id, + String message, + Boolean isRead +) { + public static NotificationInfoResDto from(Notification notification) { + return NotificationInfoResDto.builder() + .id(notification.getId()) + .message(notification.getMessage()) + .isRead(notification.getIsRead()) + .build(); + } + +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/dto/response/NotificationListResDto.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/dto/response/NotificationListResDto.java new file mode 100644 index 00000000..f145a5f9 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/api/dto/response/NotificationListResDto.java @@ -0,0 +1,20 @@ +package shop.kkeujeok.kkeujeokbackend.notification.api.dto.response; + +import java.util.List; +import lombok.Builder; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; + +@Builder +public record NotificationListResDto( + List notificationInfoResDto, + PageInfoResDto pageInfoResDto + +) { + public static NotificationListResDto of(List notificationInfoResDtoList, + PageInfoResDto pageInfoResDto) { + return NotificationListResDto.builder() + .notificationInfoResDto(notificationInfoResDtoList) + .pageInfoResDto(pageInfoResDto) + .build(); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/application/NotificationService.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/application/NotificationService.java new file mode 100644 index 00000000..0d98cb99 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/application/NotificationService.java @@ -0,0 +1,73 @@ +package shop.kkeujeok.kkeujeokbackend.notification.application; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.exception.MemberNotFoundException; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationInfoResDto; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationListResDto; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; +import shop.kkeujeok.kkeujeokbackend.notification.domain.repository.NotificationRepository; +import shop.kkeujeok.kkeujeokbackend.notification.exception.NotificationNotFoundException; +import shop.kkeujeok.kkeujeokbackend.notification.util.SseEmitterManager; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final MemberRepository memberRepository; + private final SseEmitterManager sseEmitterManager; + private final NotificationRepository notificationRepository; + + public SseEmitter createEmitter(String email) { + Member member = findMemberByEmail(email); + + return sseEmitterManager.createEmitter(member.getId()); + } + + @Transactional + public void sendNotification(Member member, String message) { + Notification notification = Notification.builder() + .receiver(member) + .message(message) + .isRead(false) + .build(); + + Notification savedNotification = notificationRepository.save(notification); + + sseEmitterManager.sendNotification(member.getId(), savedNotification.getMessage()); + } + + @Transactional(readOnly = true) + public NotificationListResDto findAllNotificationsFromMember(String email, Pageable pageable) { + Member member = findMemberByEmail(email); + Page notifications = notificationRepository.findAllNotifications(member, pageable); + + List notificationList = notifications.stream() + .map(NotificationInfoResDto::from) + .toList(); + + return NotificationListResDto.of(notificationList, PageInfoResDto.from(notifications)); + } + + @Transactional + public NotificationInfoResDto findByNotificationId(Long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(NotificationNotFoundException::new); + notification.markAsRead(); + + return NotificationInfoResDto.from(notification); + } + + private Member findMemberByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(MemberNotFoundException::new); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/Notification.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/Notification.java new file mode 100644 index 00000000..1ca94583 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/Notification.java @@ -0,0 +1,41 @@ +package shop.kkeujeok.kkeujeokbackend.notification.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import shop.kkeujeok.kkeujeokbackend.global.entity.BaseEntity; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification extends BaseEntity { + + @ManyToOne + @JoinColumn(name = "member_id") + private Member receiver; + + private String message; + + @Column(nullable = false) + private Boolean isRead; + + @Builder + public Notification(Member receiver, String message, Boolean isRead) { + this.receiver = receiver; + this.message = message; + this.isRead = isRead; + } + + public void markAsRead() { + if (isRead) { + return; + } + isRead = true; + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationCustomRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationCustomRepository.java new file mode 100644 index 00000000..3c053fc7 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationCustomRepository.java @@ -0,0 +1,10 @@ +package shop.kkeujeok.kkeujeokbackend.notification.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; + +public interface NotificationCustomRepository { + Page findAllNotifications(Member member, Pageable pageable); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationCustomRepositoryImpl.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationCustomRepositoryImpl.java new file mode 100644 index 00000000..39521295 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationCustomRepositoryImpl.java @@ -0,0 +1,45 @@ +package shop.kkeujeok.kkeujeokbackend.notification.domain.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; +import shop.kkeujeok.kkeujeokbackend.notification.domain.QNotification; + +@Repository +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationCustomRepositoryImpl implements NotificationCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findAllNotifications(Member member, Pageable pageable) { + QNotification notification = QNotification.notification; + + long total = Optional.ofNullable( + queryFactory + .select(notification.count()) + .from(notification) + .where(notification.receiver.eq(member)) + .fetchOne() + ).orElse(0L); + + List notifications = queryFactory + .selectFrom(notification) + .where(notification.receiver.eq(member)) + .orderBy(notification.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(notifications, pageable, total); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationRepository.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationRepository.java new file mode 100644 index 00000000..b2ab3b69 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/domain/repository/NotificationRepository.java @@ -0,0 +1,10 @@ +package shop.kkeujeok.kkeujeokbackend.notification.domain.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; + +public interface NotificationRepository extends JpaRepository, NotificationCustomRepository { + List findAllByReceiver(Member member); +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/exception/NotificationNotFoundException.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/exception/NotificationNotFoundException.java new file mode 100644 index 00000000..717f79c0 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/exception/NotificationNotFoundException.java @@ -0,0 +1,13 @@ +package shop.kkeujeok.kkeujeokbackend.notification.exception; + +import shop.kkeujeok.kkeujeokbackend.global.error.exception.NotFoundGroupException; + +public class NotificationNotFoundException extends NotFoundGroupException { + public NotificationNotFoundException(String message) { + super(message); + } + + public NotificationNotFoundException() { + this("존재하지 않는 알림입니다."); + } +} diff --git a/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/util/SseEmitterManager.java b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/util/SseEmitterManager.java new file mode 100644 index 00000000..348a24c9 --- /dev/null +++ b/src/main/java/shop/kkeujeok/kkeujeokbackend/notification/util/SseEmitterManager.java @@ -0,0 +1,37 @@ +package shop.kkeujeok.kkeujeokbackend.notification.util; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Slf4j +@Component +public class SseEmitterManager { + + private final Map emitters = new ConcurrentHashMap<>(); + + public SseEmitter createEmitter(Long memberId) { + SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); + emitters.put(memberId, emitter); + + emitter.onCompletion(() -> emitters.remove(memberId)); + emitter.onTimeout(() -> emitters.remove(memberId)); + emitter.onError((e) -> emitters.remove(memberId)); + + return emitter; + } + + public void sendNotification(Long memberId, String message) { + SseEmitter emitter = emitters.get(memberId); + + if (emitter != null) { + try { + emitter.send(SseEmitter.event().name("notification").data(message)); + } catch (Exception e) { + emitter.completeWithError(e); + } + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..bef7df20 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,47 @@ +spring: + profiles: + active: prod + + datasource: + url: ${spring.datasource.url} + username: ${spring.datasource.username} + password: ${spring.datasource.password} + driver-class-name: ${spring.datasource.driver-class-name} + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + show_sql: true + format_sql: true + open-in-view: false + +logging: + level: + org.hibernate.sql: debug + org.hibernate.type: trace + + discord: + webhook-uri: ${logging.discord.webhook-uri} + config: ${logging.config} + +jwt: + secret: ${jwt.secret} +token: + expire: + time: + access : ${token.expire.time.access} + refresh : ${token.expire.time.refresh} + +google: + client: + id: ${google.client.id} + secret: ${google.client.secret} + redirect: + uri: ${google.redirect.uri} + +oauth: + kakao: + rest-api-key: ${oauth.kakao.rest-api-key} + redirect-url: ${oauth.kakao.redirect-url} \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 00000000..867372a2 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,35 @@ + + + + + + + + ${DISCORD_WEBHOOK_URL} + + %d{HH:mm:ss} [%thread] [%-5level] %logger - %msg%n```%ex{full}``` + + Kkeujeok-logback + https://cdn-icons-png.flaticon.com/512/1383/1383395.png + false + + + + + ${CONSOLE_LOG_PATTERN} + utf8 + + + + + + + ERROR + + + + + + + + diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthControllerTest.java new file mode 100644 index 00000000..fd6551ab --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/api/AuthControllerTest.java @@ -0,0 +1,124 @@ +package shop.kkeujeok.kkeujeokbackend.auth.api; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.RefreshTokenReqDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.MemberLoginResDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.global.jwt.api.dto.TokenDto; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +public class AuthControllerTest extends ControllerTest { + + private Member member; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + + member = Member.builder() + .email("email") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .build(); + } + + @DisplayName("로그인하면 accessToken과 refreshToken을 반환합니다.") + @Test + public void 로그인하면_accessToken과_refreshToken을_반환합니다() throws Exception { + String provider = "google"; + TokenReqDto tokenReqDto = new TokenReqDto("auth-code"); + UserInfo userInfo = new UserInfo("email", "name", "picture", "nickname"); + MemberLoginResDto memberLoginResDto = MemberLoginResDto.from(member); + TokenDto tokenDto = new TokenDto("new-access-token", "new-refresh-token"); + + given(authServiceFactory.getAuthService(provider)).willReturn(authService); + given(authService.getUserInfo(any(String.class))).willReturn(userInfo); + given(authMemberService.saveUserInfo(any(UserInfo.class), any(SocialType.class))).willReturn(memberLoginResDto); + given(tokenService.getToken(any(MemberLoginResDto.class))).willReturn(tokenDto); + + mockMvc.perform(post("/api/google/token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(tokenReqDto))) + .andDo(print()) + .andDo(document("auth/login", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("authCode").description("ID 토큰") + ), + responseFields( + fieldWithPath("statusCode").description("응답 상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.accessToken").description("엑세스 토큰"), + fieldWithPath("data.refreshToken").description("리프레시 토큰") + ) + )) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.statusCode").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.message").value("토큰 발급")) + .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("new-refresh-token")); + + } + + @DisplayName("리프레쉬 토큰으로 액세스 토큰을 발급합니다.") + @Test + public void 리프레쉬_토큰으로_액세스_토큰을_발급합니다() throws Exception { + RefreshTokenReqDto refreshTokenReqDto = new RefreshTokenReqDto("test-refresh-token"); + TokenDto tokenDto = new TokenDto("new-access-token", "test-refresh-token"); + + given(tokenService.generateAccessToken(any(RefreshTokenReqDto.class))).willReturn(tokenDto); + + mockMvc.perform(post("/api/token/access") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(refreshTokenReqDto))) + .andDo(print()) + .andDo(document("auth/getNewAccessToken", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("refreshToken").description("리프레시 토큰") + ), + responseFields( + fieldWithPath("statusCode").description("응답 상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.accessToken").description("새로운 엑세스 토큰"), + fieldWithPath("data.refreshToken").description("새로운 리프레시 토큰") + ) + )) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.statusCode").value(200)) + .andExpect(jsonPath("$.message").value("액세스 토큰 발급")) + .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("test-refresh-token")); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberServiceTest.java new file mode 100644 index 00000000..642ff455 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthMemberServiceTest.java @@ -0,0 +1,136 @@ +package shop.kkeujeok.kkeujeokbackend.auth.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.MemberLoginResDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.Role; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.nickname.application.NicknameService; +import shop.kkeujeok.kkeujeokbackend.member.tag.application.TagService; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ActiveProfiles("test") +@ExtendWith(MockitoExtension.class) +class AuthMemberServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private NicknameService nicknameService; + + @Mock + private TagService tagService; + + @InjectMocks + private AuthMemberService authMemberService; + + private UserInfo userInfo; + private SocialType provider; + private Member member; + + @BeforeEach + void setUp() { + userInfo = new UserInfo("이메일", "이름", "사진", "닉네임"); + provider = SocialType.GOOGLE; + member = Member.builder() + .status(Status.ACTIVE) + .email(userInfo.email()) + .name(userInfo.name()) + .picture(userInfo.picture()) + .socialType(provider) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname(userInfo.nickname()) + .tag("#0000") + .build(); + } + + @DisplayName("신규 회원을 저장합니다.") + @Test + void 신규_회원을_저장합니다() { + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + when(memberRepository.save(any(Member.class))).thenReturn(member); + + MemberLoginResDto result = authMemberService.saveUserInfo(userInfo, provider); + + assertThat(result).isNotNull(); + verify(memberRepository).findByEmail(userInfo.email()); + verify(memberRepository).save(any(Member.class)); + } + + @DisplayName("회원 정보가 올바르게 저장되는지 확인합니다.") + @Test + void 회원_정보가_올바르게_저장되는지_확인합니다() { + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + when(memberRepository.save(any(Member.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + MemberLoginResDto result = authMemberService.saveUserInfo(userInfo, provider); + + assertThat(result).isNotNull(); + assertThat(result.findMember().getEmail()).isEqualTo(userInfo.email()); + assertThat(result.findMember().getName()).isEqualTo(userInfo.nickname()); + verify(memberRepository).findByEmail(userInfo.email()); + verify(memberRepository).save(any(Member.class)); + } + + @DisplayName("소셜 타입이 일치하지 않는 경우 예외를 던집니다.") + @Test + void 소셜_타입이_일치하지_않는_경우_예외를_던집니다() { + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(member)); + SocialType invalidProvider = SocialType.KAKAO; + + assertThrows(RuntimeException.class, () -> authMemberService.saveUserInfo(userInfo, invalidProvider)); + + verify(memberRepository).findByEmail(userInfo.email()); + } + + @DisplayName("이메일이 null인 경우 예외를 던집니다.") + @Test + void 이메일이_null인_경우_예외를_던집니다() { + UserInfo invalidUserInfo = new UserInfo(null, "이름", "사진", "닉네임"); + + assertThrows(RuntimeException.class, () -> authMemberService.saveUserInfo(invalidUserInfo, provider)); + } + + @DisplayName("이름이 null인 경우 예외를 던집니다.") + @Test + void 이름이_null인_경우_예외를_던집니다() { + UserInfo userInfoWithNullName = new UserInfo("이메일", null, "사진", "닉네임"); + + assertThrows(RuntimeException.class, () -> authMemberService.saveUserInfo(userInfoWithNullName, provider)); + } + + @DisplayName("사진이 null인 경우 예외를 던집니다.") + @Test + void 사진이_null인_경우_예외를_던집니다() { + UserInfo userInfoWithNullPicture = new UserInfo("이메일", "이름", null, "닉네임"); + + assertThrows(RuntimeException.class, () -> authMemberService.saveUserInfo(userInfoWithNullPicture, provider)); + } + + @DisplayName("닉네임이 null인 경우 예외를 던집니다.") + @Test + void 닉네임이_null인_경우_예외를_던집니다() { + UserInfo userInfoWithNullNickname = new UserInfo("이메일", "이름", "사진", null); + + assertThrows(RuntimeException.class, () -> authMemberService.saveUserInfo(userInfoWithNullNickname, provider)); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactoryTest.java new file mode 100644 index 00000000..d008e588 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/AuthServiceFactoryTest.java @@ -0,0 +1,46 @@ +package shop.kkeujeok.kkeujeokbackend.auth.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +@ActiveProfiles("test") +@ExtendWith(MockitoExtension.class) +class AuthServiceFactoryTest { + + @Mock + private AuthService authService1; + + @Mock + private AuthService authService2; + + private AuthServiceFactory authServiceFactory; + + @BeforeEach + void setUp() { + when(authService1.getProvider()).thenReturn("provider1"); + when(authService2.getProvider()).thenReturn("provider2"); + + List authServiceList = Arrays.asList(authService1, authService2); + authServiceFactory = new AuthServiceFactory(authServiceList); + } + + @DisplayName("특정 provider에 맞는 AuthService를 반환합니다") + @Test + void 특정_provider에_맞는_AuthService를_반환합니다() { + AuthService result = authServiceFactory.getAuthService("provider1"); + assertThat(result).isEqualTo(authService1); + + result = authServiceFactory.getAuthService("provider2"); + assertThat(result).isEqualTo(authService2); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenServiceTest.java new file mode 100644 index 00000000..e5c64461 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/auth/application/TokenServiceTest.java @@ -0,0 +1,107 @@ +package shop.kkeujeok.kkeujeokbackend.auth.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.RefreshTokenReqDto; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.MemberLoginResDto; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; +import shop.kkeujeok.kkeujeokbackend.global.jwt.api.dto.TokenDto; +import shop.kkeujeok.kkeujeokbackend.global.jwt.domain.Token; +import shop.kkeujeok.kkeujeokbackend.global.jwt.domain.repository.TokenRepository; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ActiveProfiles("test") +@ExtendWith(MockitoExtension.class) +class TokenServiceTest { + + @Mock + private TokenProvider tokenProvider; + + @Mock + private TokenRepository tokenRepository; + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private TokenService tokenService; + + private MemberLoginResDto memberLoginResDto; + private TokenDto tokenDto; + private Member member; + private Token token; + + @BeforeEach + void setUp() { + member = Member.builder().email("test@example.com").build(); + + memberLoginResDto = mock(MemberLoginResDto.class); + tokenDto = mock(TokenDto.class); + token = mock(Token.class); + + when(memberLoginResDto.findMember()).thenReturn(member); + when(tokenProvider.generateToken(anyString())).thenReturn(tokenDto); + when(tokenRepository.findByMember(any(Member.class))).thenReturn(Optional.of(token)); + when(tokenDto.refreshToken()).thenReturn("new-refresh-token"); + } + + @DisplayName("accessToken과 refreshToken을 생성합니다.") + @Test + void accessToken과_refreshToken을_생성합니다() { + when(tokenRepository.existsByMember(any(Member.class))).thenReturn(false); + + TokenDto result = tokenService.getToken(memberLoginResDto); + + assertNotNull(result); + verify(tokenProvider).generateToken(member.getEmail()); + verify(tokenRepository).existsByMember(member); + verify(tokenRepository).save(any(Token.class)); + verify(token).refreshTokenUpdate("new-refresh-token"); + } + +// 하다가 벽느낀 테스트. +// @DisplayName("refreshToken으로 accessToken를 재생성한다.") +// @Test +// void refreshToken으로_accessToken를_재생성한다.() { +// // given +// String refreshToken = "refresh-token"; +// RefreshTokenReqDto refreshTokenReqDto = new RefreshTokenReqDto(refreshToken); +// Token token = new Token(member, refreshToken); +// +// when(tokenRepository.existsByRefreshToken(refreshToken)).thenReturn(true); +// when(tokenProvider.validateToken(refreshToken)).thenReturn(true); +// when(tokenRepository.findByRefreshToken(refreshToken)).thenReturn(Optional.of(token)); +// // Here we mock the memberRepository.findById to handle null or any Long +// when(memberRepository.findById(anyLong())).thenAnswer(invocation -> { +// Long id = invocation.getArgument(0); +// return id == null ? Optional.empty() : Optional.of(member); +// }); +// when(tokenProvider.generateAccessTokenByRefreshToken(anyString(), anyString())).thenReturn(tokenDto); +// +// // when +// TokenDto result = tokenService.generateAccessToken(refreshTokenReqDto); +// +// // then +// assertNotNull(result); +// verify(tokenRepository).existsByRefreshToken(refreshToken); +// verify(tokenProvider).validateToken(refreshToken); +// verify(tokenRepository).findByRefreshToken(refreshToken); +// verify(memberRepository).findById(anyLong()); +// verify(tokenProvider).generateAccessTokenByRefreshToken(member.getEmail(), refreshToken); +// } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockControllerTest.java new file mode 100644 index 00000000..f15b02af --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/api/BlockControllerTest.java @@ -0,0 +1,415 @@ +package shop.kkeujeok.kkeujeokbackend.block.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSequenceUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockListResDto; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.exception.InvalidProgressException; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +class BlockControllerTest extends ControllerTest { + + private Member member; + private Block block; + private BlockSaveReqDto blockSaveReqDto; + private BlockUpdateReqDto blockUpdateReqDto; + private BlockSequenceUpdateReqDto blockSequenceUpdateReqDto; + + @InjectMocks + BlockController blockController; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + Dashboard dashboard = PersonalDashboard.builder() + .member(member) + .title("title") + .description("description") + .dType("PersonalDashboard") + .isPublic(false) + .category("category") + .build(); + + blockSaveReqDto = new BlockSaveReqDto(1L, "Title", "Contents", Progress.NOT_STARTED, "2024.07.03 13:23", + "2024.08.03 13:23"); + blockUpdateReqDto = new BlockUpdateReqDto("UpdateTitle", "UpdateContents", "2024.07.03 13:23", + "2024.07.28 16:40"); + block = blockSaveReqDto.toEntity(member, dashboard, 0); + + ReflectionTestUtils.setField(block, "id", 1L); + + blockSequenceUpdateReqDto = new BlockSequenceUpdateReqDto( + List.of(2L, 3L), + List.of(3L, 1L), + List.of(1L, 2L) + ); + + blockController = new BlockController(blockService); + + mockMvc = MockMvcBuilders.standaloneSetup(blockController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()) + .build(); + + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn("email"); + } + + @DisplayName("POST 블록 저장 컨트롤러 로직 확인") + @Test + void 블록_저장() throws Exception { + // given + BlockInfoResDto response = BlockInfoResDto.from(block); + given(blockService.save(anyString(), any(BlockSaveReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/blocks/") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(blockSaveReqDto))) + .andDo(print()) + .andDo(document("block/save", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + requestFields( + fieldWithPath("dashboardId").description("대시보드 아이디"), + fieldWithPath("title").description("블록 제목"), + fieldWithPath("contents").description("블록 내용"), + fieldWithPath("progress").description("블록 진행 상태"), + fieldWithPath("startDate").description("블록 시작 시간"), + fieldWithPath("deadLine").description("블록 마감 시간") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.blockId").description("블록 아이디"), + fieldWithPath("data.title").description("블록 제목"), + fieldWithPath("data.contents").description("블록 내용"), + fieldWithPath("data.progress").description("블록 진행 상태"), + fieldWithPath("data.type").description( + "블록 타입(일반(General) 블록인지 챌린지(Challenge) 블록인지 구별)"), + fieldWithPath("data.dType").description("개인 대시보드, 팀 대시보드를 구별"), + fieldWithPath("data.startDate").description("블록 시작 시간"), + fieldWithPath("data.deadLine").description("블록 마감 시간"), + fieldWithPath("data.nickname").description("회원 닉네임"), + fieldWithPath("data.dDay").description("마감 기한") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("PATCH 블록 수정 컨트롤러 로직 확인") + @Test + void 블록_수정() throws Exception { + // given + block.update(blockUpdateReqDto.title(), blockUpdateReqDto.contents(), blockUpdateReqDto.startDate(), + blockUpdateReqDto.deadLine()); + BlockInfoResDto response = BlockInfoResDto.from(block); + given(blockService.update(anyString(), anyLong(), any(BlockUpdateReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(patch("/api/blocks/{blockId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(blockUpdateReqDto))) + .andDo(print()) + .andDo(document("block/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("blockId").description("블록 ID") + ), + requestFields( + fieldWithPath("title").description("블록 제목"), + fieldWithPath("contents").description("블록 내용"), + fieldWithPath("startDate").description("블록 시작 시간"), + fieldWithPath("deadLine").description("블록 마감 시간") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.blockId").description("블록 아이디"), + fieldWithPath("data.title").description("블록 제목"), + fieldWithPath("data.contents").description("블록 내용"), + fieldWithPath("data.progress").description("블록 진행 상태"), + fieldWithPath("data.type").description( + "블록 타입(일반(General) 블록인지 챌린지(Challenge) 블록인지 구별)"), + fieldWithPath("data.dType").description("개인 대시보드, 팀 대시보드를 구별"), + fieldWithPath("data.startDate").description("블록 시작 시간"), + fieldWithPath("data.deadLine").description("블록 마감 시간"), + fieldWithPath("data.nickname").description("회원 닉네임"), + fieldWithPath("data.dDay").description("마감 기한") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("PATCH 블록 상태 수정 컨트롤러 로직 확인") + @Test + void 블록_상태_수정() throws Exception { + // given + Long blockId = 1L; + String progressString = "IN_PROGRESS"; + BlockInfoResDto response = BlockInfoResDto.from(block); + + given(blockService.progressUpdate(anyString(), anyLong(), anyString())).willReturn(response); + + // when & then + mockMvc.perform(patch(String.format("/api/blocks/{blockId}/progress?progress=%s", progressString), blockId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("block/progress/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("blockId").description("블록 ID") + ), + queryParameters( + parameterWithName("progress") + .description("블록 상태 문자열(NOT_STARTED, IN_PROGRESS, COMPLETED)") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.blockId").description("블록 아이디"), + fieldWithPath("data.title").description("블록 제목"), + fieldWithPath("data.contents").description("블록 내용"), + fieldWithPath("data.startDate").description("블록 시작 시간"), + fieldWithPath("data.deadLine").description("블록 마감 시간"), + fieldWithPath("data.type").description( + "블록 타입(일반(General) 블록인지 챌린지(Challenge) 블록인지 구별)"), + fieldWithPath("data.dType").description("개인 대시보드, 팀 대시보드를 구별"), + fieldWithPath("data.progress").description("블록 진행 상태"), + fieldWithPath("data.nickname").description("회원 닉네임"), + fieldWithPath("data.dDay").description("마감 기한") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("Patch 블록 상태 수정 실패 컨트롤러 로직 확인(400 Bad Request가 발생한다)") + @Test + void 블록_상태_수정_실패() throws Exception { + // given + Long blockId = 1L; + String progressString = "STATUS_PROGRESS"; + + given(blockService.progressUpdate(anyString(), anyLong(), anyString())).willThrow( + new InvalidProgressException()); + + // when & then + mockMvc.perform(patch(String.format("/api/blocks/{blockId}/progress?progress=%s", progressString), blockId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("block/progress/update/failure", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("blockId").description("블록 ID") + ), + queryParameters( + parameterWithName("progress") + .description("블록 상태 문자열(NOT_STARTED, IN_PROGRESS, COMPLETED)") + ) + )) + .andExpect(status().isBadRequest()); + } + + @DisplayName("Delete 블록을 논리적으로 삭제하고, 복구합니다.") + @Test + void 블록_삭제() throws Exception { + // given + Long blockId = 1L; + doNothing().when(blockService).delete(anyString(), anyLong()); + + // when & then + mockMvc.perform(delete("/api/blocks/{blockId}", blockId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("block/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("blockId").description("블록 ID") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 블록을 상태별로 전체 조회합니다.") + @Test + void 블록_상태_전체_조회() throws Exception { + // given + String progressString = "NOT_STARTED"; + Page blockPage = new PageImpl<>(List.of(block), PageRequest.of(0, 10), 1); + BlockListResDto response = BlockListResDto.from( + Collections.singletonList(BlockInfoResDto.from(block)), + PageInfoResDto.from(blockPage)); + + given(blockService.findForBlockByProgress(anyString(), anyLong(), anyString(), any())).willReturn(response); + + // when & then + mockMvc.perform(get(String.format("/api/blocks?dashboardId=%d&progress=%s", 1L, progressString)) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("block/findByBlockWithProgress", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("dashboardId") + .description("대시보드 아이디"), + parameterWithName("progress") + .description("블록 상태 문자열(NOT_STARTED, IN_PROGRESS, COMPLETED)") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.blockListResDto[].blockId").description("블록 아이디"), + fieldWithPath("data.blockListResDto[].title").description("블록 제목"), + fieldWithPath("data.blockListResDto[].contents").description("블록 내용"), + fieldWithPath("data.blockListResDto[].progress").description("블록 진행 상태"), + fieldWithPath("data.blockListResDto[].type").description( + "블록 타입(일반(General) 블록인지 챌린지(Challenge) 블록인지 구별)"), + fieldWithPath("data.blockListResDto[].dType").description("개인 대시보드, 팀 대시보드를 구별"), + fieldWithPath("data.blockListResDto[].startDate").description("블록 시작 시간"), + fieldWithPath("data.blockListResDto[].deadLine").description("블록 마감 시간"), + fieldWithPath("data.blockListResDto[].nickname").description("회원 닉네임"), + fieldWithPath("data.blockListResDto[].dDay").description("마감 기한"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 블록을 상세봅니다.") + @Test + void 블록_상세보기() throws Exception { + // given + BlockInfoResDto response = BlockInfoResDto.from(block); + + given(blockService.findById(anyString(), anyLong())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/blocks/{blockId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("block/findById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("blockId").description("블록 ID") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.blockId").description("블록 아이디"), + fieldWithPath("data.title").description("블록 제목"), + fieldWithPath("data.contents").description("블록 내용"), + fieldWithPath("data.progress").description("블록 진행 상태"), + fieldWithPath("data.type").description( + "블록 타입(일반(General) 블록인지 챌린지(Challenge) 블록인지 구별)"), + fieldWithPath("data.dType").description("개인 대시보드, 팀 대시보드를 구별"), + fieldWithPath("data.startDate").description("블록 시작 시간"), + fieldWithPath("data.deadLine").description("블록 마감 시간"), + fieldWithPath("data.nickname").description("회원 닉네임"), + fieldWithPath("data.dDay").description("마감 기한") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("PATCH 블록의 순번을 변경합니다.") + @Test + void 블록_순번_변경() throws Exception { + // given + doNothing().when(blockService).changeBlocksSequence(anyString(), any()); + + // when & then + mockMvc.perform(patch("/api/blocks/change") + .header("Authorization", "Bearer token") // 필요에 따라 토큰 추가 + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(blockSequenceUpdateReqDto))) + .andDo(print()) + .andDo(document("block/change", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("notStartedList").description("시작 전 블록 아이디 리스트"), + fieldWithPath("inProgressList").description("진행 중 블록 아이디 리스트"), + fieldWithPath("completedList").description("완료 블록 아이디 리스트") + ) + )) + .andExpect(status().isOk()); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockServiceTest.java new file mode 100644 index 00000000..91151283 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/application/BlockServiceTest.java @@ -0,0 +1,269 @@ +package shop.kkeujeok.kkeujeokbackend.block.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockSequenceUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.request.BlockUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; +import shop.kkeujeok.kkeujeokbackend.block.exception.InvalidProgressException; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository.DashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +@ExtendWith(MockitoExtension.class) +class BlockServiceTest { + + @Mock + private BlockRepository blockRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private DashboardRepository dashboardRepository; + + @InjectMocks + private BlockService blockService; + + private Member member; + private Block block; + private Block deleteBlock; + private Dashboard dashboard; + private BlockSaveReqDto blockSaveReqDto; + private BlockUpdateReqDto blockUpdateReqDto; + private BlockSequenceUpdateReqDto blockSequenceUpdateReqDto; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.ofNullable(member)); + + dashboard = PersonalDashboard.builder() + .member(member) + .title("title") + .description("description") + .isPublic(false) + .category("category") + .build(); + + blockSaveReqDto = new BlockSaveReqDto(1L, "Title", "Contents", Progress.NOT_STARTED, "2024.07.03 13:23", + "2024.07.25 13:23"); + blockUpdateReqDto = new BlockUpdateReqDto("UpdateTitle", "UpdateContents", "2024.07.03 13:23", + "2024.07.28 16:40"); + + blockSequenceUpdateReqDto = new BlockSequenceUpdateReqDto( + List.of(1L, 2L), + List.of(3L, 4L), + List.of(5L, 6L) + ); + + block = Block.builder() + .title(blockSaveReqDto.title()) + .contents(blockSaveReqDto.contents()) + .progress(blockSaveReqDto.progress()) + .startDate(blockSaveReqDto.startDate()) + .deadLine(blockSaveReqDto.deadLine()) + .member(member) + .dashboard(dashboard) + .build(); + + deleteBlock = Block.builder() + .title(blockSaveReqDto.title()) + .contents(blockSaveReqDto.contents()) + .progress(blockSaveReqDto.progress()) + .startDate(blockSaveReqDto.startDate()) + .deadLine(blockSaveReqDto.deadLine()) + .member(member) + .dashboard(dashboard) + .build(); + deleteBlock.statusUpdate(); + } + + @DisplayName("블록을 저장합니다.") + @Test + void 블록_저장() { + // given + when(blockRepository.save(any(Block.class))).thenReturn(block); + when(dashboardRepository.findById(anyLong())).thenReturn(Optional.of(dashboard)); + + // when + BlockInfoResDto result = blockService.save("email", blockSaveReqDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("Title"); + assertThat(result.contents()).isEqualTo("Contents"); + assertThat(result.progress()).isEqualTo(Progress.NOT_STARTED); + assertThat(result.deadLine()).isEqualTo("2024.07.25 13:23"); + assertNotNull(result.nickname()); + }); + } + + @DisplayName("블록을 수정합니다.") + @Test + void 블록_수정() { + // given + Long blockId = 1L; + when(blockRepository.findById(blockId)).thenReturn(Optional.of(block)); + + // when + BlockInfoResDto result = blockService.update("email", blockId, blockUpdateReqDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("UpdateTitle"); + assertThat(result.contents()).isEqualTo("UpdateContents"); + assertThat(result.deadLine()).isEqualTo("2024.07.28 16:40"); + }); + } + + @DisplayName("블록 제목과 내용이 기존과 동일하면 수정 하지 않습니다.") + @Test + void 블록_수정_X() { + // given + Long blockId = 1L; + BlockUpdateReqDto originBlockUpdateReqDto = new BlockUpdateReqDto("Title", "Contents", "2024.07.03 13:23", + "2024.07.25 13:23"); + when(blockRepository.findById(blockId)).thenReturn(Optional.of(block)); + + // when + BlockInfoResDto result = blockService.update("email", blockId, originBlockUpdateReqDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("Title"); + assertThat(result.contents()).isEqualTo("Contents"); + assertThat(block.getUpdatedAt()).isNull(); + }); + } + + @DisplayName("블록의 상태를 수정합니다.") + @Test + void 블록_상태_수정() { + // given + Long blockId = 1L; + when(blockRepository.findById(blockId)).thenReturn(Optional.of(block)); + + // when + BlockInfoResDto result = blockService.progressUpdate("email", blockId, "IN_PROGRESS"); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("Title"); + assertThat(result.contents()).isEqualTo("Contents"); + assertThat(result.progress()).isEqualTo(Progress.IN_PROGRESS); + }); + } + + @DisplayName("블록의 상태를 수정하는데 실패합니다.(Progress 문자열 파싱 실패)") + @Test + void 블록_상태_수정_실패() { + // given + Long blockId = 1L; + when(blockRepository.findById(blockId)).thenReturn(Optional.of(block)); + + // when & then + assertThatThrownBy(() -> blockService.progressUpdate("email", blockId, "String")) + .isInstanceOf(InvalidProgressException.class); + } + + @DisplayName("블록을 삭제 합니다.") + @Test + void 블록_삭제() { + // given + Long blockId = 1L; + when(blockRepository.findById(blockId)).thenReturn(Optional.of(block)); + + // when + blockService.delete("email", blockId); + + // then + assertAll(() -> { + assertThat(block.getStatus()).isEqualTo(Status.DELETED); + }); + } + + @DisplayName("삭제되었던 블록을 복구합니다.") + @Test + void 블록_복구() { + // given + Long blockId = 1L; + when(blockRepository.findById(blockId)).thenReturn(Optional.of(deleteBlock)); + + // when + blockService.delete("email", blockId); + + // then + assertAll(() -> { + assertThat(deleteBlock.getStatus()).isEqualTo(Status.ACTIVE); + }); + } + + @DisplayName("블록을 상세 봅니다.") + @Test + void 블록_상세보기() { + // given + Long blockId = 1L; + when(blockRepository.findById(blockId)).thenReturn(Optional.of(block)); + + // when + BlockInfoResDto result = blockService.findById("email", blockId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("Title"); + assertThat(result.contents()).isEqualTo("Contents"); + assertThat(result.progress()).isEqualTo(Progress.NOT_STARTED); + assertThat(result.deadLine()).isEqualTo("2024.07.25 13:23"); + assertNotNull(result.nickname()); + }); + } + + @DisplayName("블록의 순번을 변경합니다.") + @Test + void 블록_순번_변경() { + // given + when(blockRepository.findById(anyLong())).thenReturn(Optional.of(block)); + + // when + blockService.changeBlocksSequence("email", blockSequenceUpdateReqDto); + + // then + verify(blockRepository, times(6)).findById(anyLong()); // 6번 블록 조회가 이루어졌는지 검증 + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/block/domain/BlockTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/domain/BlockTest.java new file mode 100644 index 00000000..a7977f27 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/domain/BlockTest.java @@ -0,0 +1,151 @@ +package shop.kkeujeok.kkeujeokbackend.block.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +class BlockTest { + private String title; + private String contents; + private String startDate; + private String deadLine; + + private Block block; + + @BeforeEach + void setUp() { + title = "title"; + contents = "contents"; + startDate = "2024.07.03 22:34"; + deadLine = "2024.07.27 22:34"; + + block = Block.builder() + .title(title) + .contents(contents) + .progress(Progress.NOT_STARTED) + .startDate(startDate) + .deadLine(deadLine) + .sequence(1) + .build(); + } + + @DisplayName("블록의 모든 값을 수정합니다.") + @Test + void 블록_수정() { + // given + String updateTitle = "updateTitle"; + String updateContents = "updateContents"; + String updateStartDate = "updateStartDate"; + String updateDeadLine = "updateDeadLine"; + + // when + block.update(updateTitle, updateContents, updateStartDate, updateDeadLine); + + // then + assertAll(() -> { + assertThat(block.getTitle()).isEqualTo(updateTitle); + assertThat(block.getContents()).isEqualTo(updateContents); + assertThat(block.getDeadLine()).isEqualTo(updateDeadLine); + }); + } + + @DisplayName("블록의 제목만 수정합니다.") + @Test + void 블록_제목_수정() { + // given + String updateTitle = "updateTitle"; + + // when + block.update(updateTitle, contents, startDate, deadLine); + + // then + assertAll(() -> { + assertThat(block.getTitle()).isEqualTo(updateTitle); + assertThat(block.getContents()).isEqualTo(contents); + assertThat(block.getDeadLine()).isEqualTo(deadLine); + }); + } + + @DisplayName("블록의 내용만 수정합니다.") + @Test + void 블록_내용_수정() { + // given + String updateContents = "updateContents"; + + // when + block.update(title, updateContents, startDate, deadLine); + + // then + assertAll(() -> { + assertThat(block.getTitle()).isEqualTo(title); + assertThat(block.getContents()).isEqualTo(updateContents); + assertThat(block.getDeadLine()).isEqualTo(deadLine); + }); + } + + @DisplayName("블록의 마감 기한만 수정합니다.") + @Test + void 블록_마감_기한_수정() { + // given + String updateDeadLine = "2024.07.28 22:34"; + + // when + block.update(title, contents, startDate, updateDeadLine); + + // then + assertAll(() -> { + assertThat(block.getTitle()).isEqualTo(title); + assertThat(block.getContents()).isEqualTo(contents); + assertThat(block.getDeadLine()).isEqualTo(updateDeadLine); + }); + } + + @DisplayName("블록의 진행 상태를 수정합니다.") + @Test + void 블록_진행_상태_수정() { + // given + Progress progress = Progress.IN_PROGRESS; + + // when + block.progressUpdate(progress); + + // then + assertThat(block.getProgress()).isEqualTo(progress); + } + + @DisplayName("블록의 논리 삭제 상태를 수정합니다.") + @Test + void 블록_논리_삭제_수정() { + // given + assertThat(block.getStatus()).isEqualTo(Status.ACTIVE); + + // when + block.statusUpdate(); + + // then + assertThat(block.getStatus()).isEqualTo(Status.DELETED); + + // when + block.statusUpdate(); + + // then + assertThat(block.getStatus()).isEqualTo(Status.ACTIVE); + } + + @DisplayName("블록의 순번이 변경됩니다.") + @Test + void 블록_순번_변경() { + // given + + // when + block.sequenceUpdate(5); + + // then + assertThat(block.getSequence()).isEqualTo(5); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockRepositoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockRepositoryTest.java new file mode 100644 index 00000000..f088bce0 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/block/domain/repository/BlockRepositoryTest.java @@ -0,0 +1,142 @@ +package shop.kkeujeok.kkeujeokbackend.block.domain.repository; + + +import static org.assertj.core.api.Assertions.assertThat; + +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.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.Dashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.domain.repository.DashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.global.config.JpaAuditingConfig; +import shop.kkeujeok.kkeujeokbackend.global.config.QuerydslConfig; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +@DataJpaTest +@Import({JpaAuditingConfig.class, QuerydslConfig.class}) +@ActiveProfiles("test") +class BlockRepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BlockRepository blockRepository; + + @Autowired + private DashboardRepository dashboardRepository; + + private Member member; + private Dashboard dashboard; + private Block block1; + private Block block2; + private Block block3; + + + @BeforeEach + void setUp() { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + dashboard = PersonalDashboard.builder() + .member(member) + .title("title") + .description("description") + .isPublic(false) + .category("category") + .build(); + + block1 = Block.builder() + .title("title1") + .contents("contents1") + .progress(Progress.NOT_STARTED) + .deadLine("2024.07.27 11:03") + .dashboard(dashboard) + .sequence(1) + .build(); + + block2 = Block.builder() + .title("title2") + .contents("contents2") + .progress(Progress.NOT_STARTED) + .deadLine("2024.07.27 11:04") + .dashboard(dashboard) + .sequence(2) + .build(); + + block3 = Block.builder() + .title("title3") + .contents("contents3") + .progress(Progress.IN_PROGRESS) + .deadLine("2024.07.27 11:05") + .dashboard(dashboard) + .sequence(3) + .build(); + + memberRepository.save(member); + dashboardRepository.save(dashboard); + blockRepository.save(block1); + blockRepository.save(block2); + blockRepository.save(block3); + } + + @DisplayName("블록을 진행 상태별로 전체 조회합니다.") + @Test + void 블록_진행_상태_전체_조회() { + // given + Progress progress = Progress.NOT_STARTED; + Pageable pageable = PageRequest.of(0, 10); + + // when + Page blocks = blockRepository.findByBlockWithProgress(dashboard.getId(), progress, pageable); + + // then + assertThat(blocks.getContent().size()).isEqualTo(2); + } + + @DisplayName("블록을 논리 삭제 상태별로 전체 조회합니다.") + @Test + void 블록_삭제_상태_전체_조회() { + // given + Progress progress = Progress.NOT_STARTED; + Pageable pageable = PageRequest.of(0, 10); + block1.statusUpdate(); + + // when + Page blocks = blockRepository.findByBlockWithProgress(1L, progress, pageable); + + // then + assertThat(blocks.getContent().size()).isEqualTo(1); + } + + @DisplayName("블록의 마지막 순번을 가져옵니다.") + @Test + void 블록_마지막_순번() { + // given + + // when + int lastSequence = blockRepository.findLastSequenceByProgress(member, dashboard.getId(), Progress.NOT_STARTED); + + // then + assertThat(lastSequence).isEqualTo(2); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/api/ChallengeControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/api/ChallengeControllerTest.java new file mode 100644 index 00000000..0ad8ecef --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/api/ChallengeControllerTest.java @@ -0,0 +1,462 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSearchReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeInfoResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Category; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Cycle; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.Role; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +@ExtendWith(RestDocumentationExtension.class) +class ChallengeControllerTest extends ControllerTest { + + private static final String AUTHORIZATION_HEADER_NAME = "Authorization"; + private static final String AUTHORIZATION_HEADER_VALUE = "Bearer valid-token"; + + private Member member; + private Challenge challenge; + private ChallengeSaveReqDto challengeSaveReqDto; + private ChallengeSaveReqDto challengeUpdateReqDto; + private ChallengeSearchReqDto challengeSearchReqDto; + + @InjectMocks + ChallengeController challengeController; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + member = Member.builder() + .status(Status.ACTIVE) + .email("kkeujeok@gmail.com") + .name("김동균") + .picture("기본 프로필") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("동동") + .build(); + + challengeSaveReqDto = new ChallengeSaveReqDto("1일 1커밋", + "1일 1커밋하기", + Category.CREATIVITY_AND_ARTS, + Cycle.WEEKLY, + List.of(CycleDetail.MON, CycleDetail.TUE), + LocalDate.now(), + LocalDate.now().plusDays(30), + "대표 이미지"); + + challenge = Challenge.builder() + .title(challengeSaveReqDto.title()) + .contents(challengeSaveReqDto.title()) + .cycleDetails(challengeSaveReqDto.cycleDetails()) + .startDate(challengeSaveReqDto.startDate()) + .endDate(challengeSaveReqDto.endDate()) + .representImage(challengeSaveReqDto.representImage()) + .member(member) + .build(); + + challengeUpdateReqDto = new ChallengeSaveReqDto( + "업데이트 제목", + "업데이트 내용", + Category.CREATIVITY_AND_ARTS, + Cycle.WEEKLY, + List.of(CycleDetail.MON), + LocalDate.now(), + LocalDate.now().plusDays(30), + "업데이트 이미지"); + + challengeSearchReqDto = new ChallengeSearchReqDto("1일"); + + challengeController = new ChallengeController(challengeService); + + mockMvc = MockMvcBuilders.standaloneSetup(challengeController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()).build(); + + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))) + .thenReturn("kkeujeok@gmail.com"); + } + + @Test + @DisplayName("챌린지 생성 성공 시 상태코드 201 반환") + void 챌린지_생성_성공_시_상태코드_201_반환() throws Exception { + // given + ChallengeInfoResDto response = ChallengeInfoResDto.from(challenge); + given(challengeService.save(anyString(), any(ChallengeSaveReqDto.class))) + .willReturn(response); + + // when & then + mockMvc.perform( + post("/api/challenges") + .header(AUTHORIZATION_HEADER_NAME, + AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(challengeSaveReqDto))) + .andDo(print()) + .andDo(document("challenge/save", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AUTHORIZATION_HEADER_NAME).description("JWT 토큰")), + requestFields(fieldWithPath("title").description("챌린지 제목"), + fieldWithPath("contents").description("챌린지 내용"), + fieldWithPath("category").description("챌린지 카테고리"), + fieldWithPath("cycle").description("챌린지 주기"), + fieldWithPath("cycleDetails").description("주기 상세정보"), + fieldWithPath("startDate").description("시작 날짜"), + fieldWithPath("endDate").description("종료 날짜"), + fieldWithPath("representImage").description("대표 사진")), + responseFields(fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.title").description("챌린지 제목"), + fieldWithPath("data.contents").description("챌린지 내용"), + fieldWithPath("data.category").description("챌린지 카테고리"), + fieldWithPath("data.cycle").description("챌린지 주기"), + fieldWithPath("data.cycleDetails").description("주기 상세정보"), + fieldWithPath("data.startDate").description("시작 날짜"), + fieldWithPath("data.endDate").description("종료 날짜"), + fieldWithPath("data.representImage").description("대표 사진"), + fieldWithPath("data.authorName").description("챌린지 작성자 이름"), + fieldWithPath("data.authorProfileImage").description("챌린지 작성자 프로필 이미지") + )) + ) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("블록 수정에 성공하면 상태코드 200 반환") + void 블록_수정에_성공하면_상태코드_200_반환() throws Exception { + // given + challenge.update(challengeUpdateReqDto.title(), + challengeUpdateReqDto.contents(), + challengeUpdateReqDto.cycleDetails(), + challengeUpdateReqDto.startDate(), + challengeUpdateReqDto.endDate(), + challengeUpdateReqDto.representImage()); + ChallengeInfoResDto response = ChallengeInfoResDto.from(challenge); + given(challengeService.update(anyString(), anyLong(), any(ChallengeSaveReqDto.class))) + .willReturn(response); + + // when & then + mockMvc.perform( + patch("/api/challenges/{challengeId}", 1L) + .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(challengeSaveReqDto))) + .andDo(print()) + .andDo(document("challenge/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AUTHORIZATION_HEADER_NAME).description("JWT 토큰")), + pathParameters(parameterWithName("challengeId").description("챌린지 ID")), + requestFields(fieldWithPath("title").description("챌린지 제목"), + fieldWithPath("contents").description("챌린지 내용"), + fieldWithPath("category").description("챌린지 카테고리"), + fieldWithPath("cycle").description("챌린지 주기"), + fieldWithPath("cycleDetails").description("주기 상세정보"), + fieldWithPath("startDate").description("시작 날짜"), + fieldWithPath("endDate").description("종료 날짜"), + fieldWithPath("representImage").description("대표 사진")), + responseFields(fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.title").description("챌린지 제목"), + fieldWithPath("data.contents").description("챌린지 내용"), + fieldWithPath("data.category").description("챌린지 카테고리"), + fieldWithPath("data.cycle").description("챌린지 주기"), + fieldWithPath("data.cycleDetails").description("주기 상세정보"), + fieldWithPath("data.startDate").description("시작 날짜"), + fieldWithPath("data.endDate").description("종료 날짜"), + fieldWithPath("data.representImage").description("대표 사진"), + fieldWithPath("data.authorName").description("챌린지 작성자 이름"), + fieldWithPath("data.authorProfileImage").description("챌린지 작성자 프로필 이미지")))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("챌린지 전체 조회에 성공하면 상태코드 200 반환") + void 챌린지_전체_조회에_성공하면_상태코드_200_반환() throws Exception { + // given + ChallengeInfoResDto challengeInfoResDto = ChallengeInfoResDto.from(challenge); + Page challengePage = new PageImpl<>(List.of(challenge), + PageRequest.of(0, 10), 1); + ChallengeListResDto response = ChallengeListResDto.of(List.of(challengeInfoResDto), + PageInfoResDto.from(challengePage)); + given(challengeService.findAllChallenges(any(PageRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform( + get("/api/challenges") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("challenge/findAll", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields(fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.challengeInfoResDto[].title").description("챌린지 제목"), + fieldWithPath("data.challengeInfoResDto[].contents").description("챌린지 내용"), + fieldWithPath("data.challengeInfoResDto[].category").description("챌린지 카테고리"), + fieldWithPath("data.challengeInfoResDto[].cycle").description("챌린지 주기"), + fieldWithPath("data.challengeInfoResDto[].cycleDetails[]").description("주기 상세정보"), + fieldWithPath("data.challengeInfoResDto[].startDate").description("시작 날짜"), + fieldWithPath("data.challengeInfoResDto[].endDate").description("종료 날짜"), + fieldWithPath("data.challengeInfoResDto[].representImage").description("대표 사진"), + fieldWithPath("data.challengeInfoResDto[].authorName").description("챌린지 작성자 이름"), + fieldWithPath("data.challengeInfoResDto[].authorProfileImage").description( + "챌린지 작성자 프로필 이미지"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템") + + ))).andExpect(status().isOk()); + } + + @Test + @DisplayName("검색에 성공하면 상태코드 200 반환") + void 검색에_성공하면_상태코드_200_반환() throws Exception { + // given + ChallengeInfoResDto challengeInfoResDto = ChallengeInfoResDto.from(challenge); + Page challengePage = new PageImpl<>(List.of(challenge), + PageRequest.of(0, 10), 1); + ChallengeListResDto response = ChallengeListResDto.of(List.of(challengeInfoResDto), + PageInfoResDto.from(challengePage)); + given(challengeService.findChallengesByKeyWord(any(ChallengeSearchReqDto.class), any(PageRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform( + get("/api/challenges/search?keyword=%s", + challengeSearchReqDto.keyWord()) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("challenge/search", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), + responseFields(fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.challengeInfoResDto[].title").description("챌린지 제목"), + fieldWithPath("data.challengeInfoResDto[].contents").description("챌린지 내용"), + fieldWithPath("data.challengeInfoResDto[].category").description("챌린지 카테고리"), + fieldWithPath("data.challengeInfoResDto[].cycle").description("챌린지 주기"), + fieldWithPath("data.challengeInfoResDto[].cycleDetails[]").description("주기 상세정보"), + fieldWithPath("data.challengeInfoResDto[].startDate").description("시작 날짜"), + fieldWithPath("data.challengeInfoResDto[].endDate").description("종료 날짜"), + fieldWithPath("data.challengeInfoResDto[].representImage").description("대표 사진"), + fieldWithPath("data.challengeInfoResDto[].authorName").description("챌린지 작성자 이름"), + fieldWithPath("data.challengeInfoResDto[].authorProfileImage") + .description("챌린지 작성자 프로필 이미지"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템") + + )) + ) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("챌린지 카테고리 별 검색에 성공하면 상태코드 200 반환") + void 챌린지_카테고리_별_검색에_성공하면_상태코드_200_반환() throws Exception { + // given + ChallengeInfoResDto challengeInfoResDto = ChallengeInfoResDto.from(challenge); + Page challengePage = new PageImpl<>(List.of(challenge), + PageRequest.of(0, 10), 1); + ChallengeListResDto response = ChallengeListResDto.of(List.of(challengeInfoResDto), + PageInfoResDto.from(challengePage)); + + given(challengeService.findByCategory(anyString(), any(PageRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform( + get("/api/challenges/find?category=%s", + Category.CREATIVITY_AND_ARTS) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("challenge/category", preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields(fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.challengeInfoResDto[].title").description("챌린지 제목"), + fieldWithPath("data.challengeInfoResDto[].contents").description("챌린지 내용"), + fieldWithPath("data.challengeInfoResDto[].category").description("챌린지 카테고리"), + fieldWithPath("data.challengeInfoResDto[].cycle").description("챌린지 주기"), + fieldWithPath("data.challengeInfoResDto[].cycleDetails[]").description("주기 상세정보"), + fieldWithPath("data.challengeInfoResDto[].startDate").description("시작 날짜"), + fieldWithPath("data.challengeInfoResDto[].endDate").description("종료 날짜"), + fieldWithPath("data.challengeInfoResDto[].representImage").description("대표 사진"), + fieldWithPath("data.challengeInfoResDto[].authorName").description("챌린지 작성자 이름"), + fieldWithPath("data.challengeInfoResDto[].authorProfileImage") + .description("챌린지 작성자 프로필 이미지"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템") + )) + ).andExpect(status().isOk()); + } + + @Test + @DisplayName("챌린지 상세 정보 조회에 성공하면 상태코드 200 반환") + void 챌린지_상세_조회에_성공하면_상태코드_200_반환() throws Exception { + // given + ChallengeInfoResDto response = ChallengeInfoResDto.from(challenge); + given(challengeService.findById(anyLong())) + .willReturn(response); + + // when & then + mockMvc.perform( + get("/api/challenges/{challengeId}", 1L) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("challenge/findById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters(parameterWithName("challengeId").description("챌린지 ID")), + responseFields(fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.title").description("챌린지 제목"), + fieldWithPath("data.contents").description("챌린지 내용"), + fieldWithPath("data.category").description("챌린지 카테고리"), + fieldWithPath("data.cycle").description("챌린지 주기"), + fieldWithPath("data.cycleDetails").description("주기 상세정보"), + fieldWithPath("data.startDate").description("시작 날짜"), + fieldWithPath("data.endDate").description("종료 날짜"), + fieldWithPath("data.representImage").description("대표 사진"), + fieldWithPath("data.authorName").description("챌린지 작성자 이름"), + fieldWithPath("data.authorProfileImage").description("챌린지 작성자 프로필 이미지") + )) + ) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("챌린지 삭제에 성공하면 상태코드 200 반환") + void 챌린지_삭제에_성공하면_상태코드_200_반환() throws Exception { + // given + willDoNothing().given(challengeService).delete(anyString(), anyLong()); + + // when & then + mockMvc.perform(delete("/api/challenges/{challengeId}", 1L) + .header(AUTHORIZATION_HEADER_NAME, + AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(challengeSaveReqDto))) + .andDo(print()) + .andDo(document("challenge/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AUTHORIZATION_HEADER_NAME).description("JWT 토큰")), + pathParameters(parameterWithName("challengeId").description("챌린지 ID") + )) + ) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("챌린지를 개인 대시보드에 추가 성공 시 상태코드 200 반환") + void 챌린지를_개인_대시보드에_추가_성공_시_상태코드_200_반환() throws Exception { + // given + BlockInfoResDto blockInfoResDto = new BlockInfoResDto(1L, + "1일 1커밋", + "1일 1커밋하기", + Progress.NOT_STARTED, + Type.CHALLENGE, + "PersonalDashboard", + "2024.09.31 23:59", + "2024.09.31 23:59", + "동동", + 0); + + given(challengeService.addChallengeToPersonalDashboard(anyString(), anyLong(), anyLong())) + .willReturn(blockInfoResDto); + + // when & then + mockMvc.perform(post("/api/challenges/{challengeId}/{dashboardId}", 1L, 1L) + .header(AUTHORIZATION_HEADER_NAME, + AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(blockInfoResDto))) + .andDo(print()) + .andDo(document("challenge/addChallengeToPersonalDashboard", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AUTHORIZATION_HEADER_NAME).description("JWT 토큰")), + pathParameters(parameterWithName("challengeId").description("챌린지 ID"), + parameterWithName("dashboardId").description("대시보드 ID")), + responseFields(fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.blockId").description("블록 ID"), + fieldWithPath("data.title").description("블록 제목"), + fieldWithPath("data.contents").description("블록 내용"), + fieldWithPath("data.progress").description("블록 진행도"), + fieldWithPath("data.type").description( + "블록 타입(일반(General) 블록인지 챌린지(Challenge) 블록인지 구별)"), + fieldWithPath("data.dType").description("개인 대시보드, 팀 대시보드를 구별"), + fieldWithPath("data.startDate").description("블록 시작기한"), + fieldWithPath("data.deadLine").description("블록 마감기한"), + fieldWithPath("data.nickname").description("블록 작성자"), + fieldWithPath("data.dDay").description("블록 디데이") + )) + ) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/application/ChallengeServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/application/ChallengeServiceTest.java new file mode 100644 index 00000000..b42632f4 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/application/ChallengeServiceTest.java @@ -0,0 +1,399 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.block.api.dto.response.BlockInfoResDto; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.Type; +import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.reqeust.ChallengeSearchReqDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeInfoResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Category; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Challenge; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Cycle; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.repository.ChallengeRepository; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.ChallengeAccessDeniedException; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.InvalidCycleException; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.repository.PersonalDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.Role; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.exception.MemberNotFoundException; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; + +@ExtendWith(MockitoExtension.class) +class ChallengeServiceTest { + + private Member member; + private Challenge challenge; + private ChallengeSaveReqDto updateDto; + private ChallengeSaveReqDto challengeSaveReqDto; + private PersonalDashboard personalDashboard; + private Block block; + private PersonalDashboardSaveReqDto personalDashboardSaveReqDto; + + + @Mock + private ChallengeRepository challengeRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private PersonalDashboardRepository personalDashboardRepository; + + @Mock + private BlockRepository blockRepository; + + @Mock + private NotificationService notificationService; + + @InjectMocks + private ChallengeService challengeService; + + + @BeforeEach + void setUp() { + member = Member.builder() + .status(Status.ACTIVE) + .email("kkeujeok@gmail.com") + .name("김동균") + .picture("기본 프로필") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("동동") + .build(); + + lenient().when(memberRepository.findByEmail(anyString())) + .thenReturn(Optional.ofNullable(member)); + + challengeSaveReqDto = new ChallengeSaveReqDto( + "1일 1커밋", + "1일 1커밋하기", + Category.CREATIVITY_AND_ARTS, + Cycle.WEEKLY, + List.of(CycleDetail.MON, CycleDetail.TUE), + LocalDate.now(), + LocalDate.now().plusDays(30), + "대표 이미지" + ); + + challenge = Challenge.builder() + .title(challengeSaveReqDto.title()) + .contents(challengeSaveReqDto.contents()) + .cycle(challengeSaveReqDto.cycle()) + .cycleDetails(challengeSaveReqDto.cycleDetails()) + .startDate(challengeSaveReqDto.startDate()) + .endDate(challengeSaveReqDto.endDate()) + .representImage(challengeSaveReqDto.representImage()) + .member(member) + .build(); + + updateDto = new ChallengeSaveReqDto( + "업데이트 제목", + "업데이트 내용", + Category.CREATIVITY_AND_ARTS, + Cycle.WEEKLY, + List.of(CycleDetail.MON), + LocalDate.now(), + LocalDate.now().plusDays(30), + "업데이트 이미지" + ); + + block = Block.builder() + .title(challenge.getTitle()) + .contents(challenge.getContents()) + .progress(Progress.NOT_STARTED) + .type(Type.CHALLENGE) + .deadLine(LocalDate.now() + .format(DateTimeFormatter.ofPattern("yyyy.MM.dd 23:59"))) + .member(member) + .dashboard(personalDashboard) + .challenge(challenge) + .build(); + + personalDashboardSaveReqDto = new PersonalDashboardSaveReqDto("개인 대시보드", + "테스트용 대시보드", + false, + "category"); + + personalDashboard = PersonalDashboard.builder() + .title(personalDashboardSaveReqDto.title()) + .description(personalDashboardSaveReqDto.description()) + .isPublic(personalDashboardSaveReqDto.isPublic()) + .category(personalDashboardSaveReqDto.category()) + .member(member) + .build(); + } + + @Test + @DisplayName("인증된 회원은 챌린지를 생성할 수 있다") + void 인증된_회원은_챌린지를_생성할_수_있다() { + + // given + when(challengeRepository.save(any(Challenge.class))) + .thenReturn(challenge); + // when + ChallengeInfoResDto result = challengeService.save(member.getEmail(), challengeSaveReqDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("1일 1커밋"); + assertThat(result.contents()).isEqualTo("1일 1커밋하기"); + assertThat(result.cycleDetails()).isEqualTo(List.of(CycleDetail.MON, CycleDetail.TUE)); + assertThat(result.startDate()).isEqualTo(LocalDate.now()); + assertThat(result.endDate()).isEqualTo(LocalDate.now().plusDays(30)); + assertThat(result.representImage()).isEqualTo("대표 이미지"); + assertThat(result.authorName()).isEqualTo("동동"); + assertThat(result.authorProfileImage()).isEqualTo("기본 프로필"); + }); + } + + @Test + @DisplayName("주기 정보와 세부 주기가 일치하지 않는다면 예외가 발생한다") + void 주기_정보와_세부_주기가_일치하지_않는다면_예외가_발생한다() { + // given + ChallengeSaveReqDto wrongChallengeSaveReqDto = new ChallengeSaveReqDto( + "1일 1커밋", + "1일 1커밋하기", + Category.CREATIVITY_AND_ARTS, + Cycle.MONTHLY, + List.of(CycleDetail.MON, CycleDetail.TUE), + LocalDate.now(), + LocalDate.now().plusDays(30), + "대표 이미지" + ); + + // when & then + assertThatThrownBy(() -> challengeService.save(member.getEmail(), wrongChallengeSaveReqDto)) + .isInstanceOf(InvalidCycleException.class); + } + + @Test + @DisplayName("회원 정보가 없다면 챌린지를 생성하려 하면 예외가 발생한다") + void 회원_정보가_없다면_챌린지를_생성하려_하면_예외가_발생한다() { + // given + String errorEmail = "존재하지 않는 이메일@example.com"; + + when(memberRepository.findByEmail(errorEmail)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> challengeService.save(errorEmail, challengeSaveReqDto)) + .isInstanceOf(MemberNotFoundException.class); + } + + @Test + @DisplayName("챌린지 작성자는 챌린지 정보를 업데이트할 수 있다") + void 챌린지_작성자는_챌린지_정보를_업데이트할_수_있다() { + // given + Long challengeId = 1L; + when(challengeRepository.findById(challengeId)) + .thenReturn(Optional.of(challenge)); + + // when + ChallengeInfoResDto result = challengeService.update(member.getEmail(), challengeId, updateDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("업데이트 제목"); + assertThat(result.contents()).isEqualTo("업데이트 내용"); + assertThat(result.cycleDetails()).containsExactly(CycleDetail.MON); + assertThat(result.startDate()).isEqualTo(LocalDate.now()); + assertThat(result.endDate()).isEqualTo(LocalDate.now().plusDays(30)); + assertThat(result.representImage()).isEqualTo("업데이트 이미지"); + }); + } + + @Test + @DisplayName("챌린지 작성자가 아니면 수정 시 예외가 발생한다") + void 챌린지_작성자가_아니면_수정_시_예외가_발생한다() { + // given + Long challengeId = 1L; + Member otherMember = Member.builder() + .status(Status.ACTIVE) + .email("other@example.com") + .name("다른 사용자") + .picture("다른 프로필") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("달라") + .build(); + + when(challengeRepository.findById(challengeId)) + .thenReturn(Optional.of(challenge)); + when(memberRepository.findByEmail("other@example.com")) + .thenReturn(Optional.of(otherMember)); + + // when & then + assertThatThrownBy(() -> challengeService.update("other@example.com", challengeId, updateDto)) + .isInstanceOf(ChallengeAccessDeniedException.class); + } + + @Test + @DisplayName("모든챌린지를 조회할 수 있다") + void 모든_챌린지를_조회할_수_있다() { + // given + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(challenge), PageRequest.of(0, 10), 1); + when(challengeRepository.findAllChallenges(any(Pageable.class))) + .thenReturn(page); + + // when + ChallengeListResDto result = challengeService.findAllChallenges(pageable); + + // then + assertAll(() -> { + assertThat(result.challengeInfoResDto().size()).isEqualTo(1); + assertThat(result.pageInfoResDto().totalPages()).isEqualTo(1); + assertThat(result.pageInfoResDto().totalItems()).isEqualTo(1); + }); + } + + @Test + @DisplayName("챌린지 목록을 검색할 수 있다") + void 챌린지_목록을_검색할_수_있다() { + // given + Pageable pageable = PageRequest.of(0, 10); + ChallengeSearchReqDto searchReqDto = ChallengeSearchReqDto.from("1일"); + Page page = new PageImpl<>(List.of(challenge), pageable, 1); + when(challengeRepository.findChallengesByKeyWord(any(ChallengeSearchReqDto.class), any(PageRequest.class))) + .thenReturn(page); + + // when + ChallengeListResDto result = challengeService.findChallengesByKeyWord(searchReqDto, pageable); + + // then + assertAll(() -> { + assertThat(result.challengeInfoResDto().size()).isEqualTo(1); + assertThat(result.pageInfoResDto().totalPages()).isEqualTo(1); + assertThat(result.pageInfoResDto().totalItems()).isEqualTo(1); + }); + } + + @Test + @DisplayName("챌린지를 카테고리 별로 검색할 수 있다") + void 챌린지를_카테고리_별로_검색할_수_있다() { + //given + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(challenge), pageable, 1); + when(challengeRepository.findChallengesByCategory(anyString(), any(PageRequest.class))) + .thenReturn(page); + + // when + ChallengeListResDto result = challengeService.findByCategory("CREATIVITY_AND_ARTS", pageable); + + // then + assertAll(() -> { + assertThat(result.challengeInfoResDto().size()).isEqualTo(1); + assertThat(result.pageInfoResDto().totalPages()).isEqualTo(1); + assertThat(result.pageInfoResDto().totalItems()).isEqualTo(1); + }); + } + + @Test + @DisplayName("챌린지 상세정보를 조회할 수 있다") + void 챌린지_상세정보를_조회할_수_있다() { + // given + Long challengeId = 1L; + when(challengeRepository.findById(challengeId)).thenReturn(Optional.of(challenge)); + + // when + ChallengeInfoResDto result = challengeService.findById(challengeId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("1일 1커밋"); + assertThat(result.contents()).isEqualTo("1일 1커밋하기"); + assertThat(result.cycleDetails()).isEqualTo(List.of(CycleDetail.MON, CycleDetail.TUE)); + assertThat(result.startDate()).isEqualTo(LocalDate.now()); + assertThat(result.endDate()).isEqualTo(LocalDate.now().plusDays(30)); + assertThat(result.representImage()).isEqualTo("대표 이미지"); + assertThat(result.authorName()).isEqualTo("동동"); + assertThat(result.authorProfileImage()).isEqualTo("기본 프로필"); + }); + } + + @Test + @DisplayName("챌린지 작성자가 아니면 삭제할 수 없다") + void 챌린지_작성자가_아니면_삭제할_수_없다() { + // given + Long challengeId = 1L; + Member otherMember = Member.builder() + .status(Status.ACTIVE) + .email("other@example.com") + .name("다른 사용자") + .picture("다른 프로필") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("달라") + .build(); + when(challengeRepository.findById(challengeId)) + .thenReturn(Optional.of(challenge)); + when(memberRepository.findByEmail("other@example.com")) + .thenReturn(Optional.of(otherMember)); + + // when & then + assertThatThrownBy(() -> challengeService.delete("other@example.com", challengeId)) + .isInstanceOf(ChallengeAccessDeniedException.class); + } + + @Test + @DisplayName("챌린지를 개인 대시보드에 추가할 수 있다") + void 챌린지를_개인_대시보드에_추가할_수_있다() { + // given + Long personalDashboardId = 1L; + Long challengeId = 1L; + + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(member)); + when(challengeRepository.findById(anyLong())).thenReturn(Optional.of(challenge)); + when(personalDashboardRepository.findById(anyLong())).thenReturn(Optional.of(personalDashboard)); + when(blockRepository.save(any(Block.class))).thenReturn(block); + + // when + BlockInfoResDto result = challengeService.addChallengeToPersonalDashboard(member.getEmail(), + personalDashboardId, challengeId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("1일 1커밋"); + assertThat(result.contents()).isEqualTo("1일 1커밋하기"); + assertThat(result.progress()).isEqualTo(Progress.NOT_STARTED); + assertThat(result.deadLine()).isEqualTo( + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd 23:59"))); + }); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUtilTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUtilTest.java new file mode 100644 index 00000000..630f4995 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/application/util/ChallengeBlockStatusUtilTest.java @@ -0,0 +1,89 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.application.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.Cycle; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; + +class ChallengeBlockStatusUtilTest { + + @Test + @DisplayName("DAILY 주기는 항상 true를 반환한다") + void DAILY_주기는_항상_true를_반환한다() { + // given + List cycleDetails = List.of(CycleDetail.DAILY); + + // when + Boolean result = ChallengeBlockStatusUtil.isChallengeBlockActiveToday(Cycle.DAILY, cycleDetails); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("WEEKLY 주기에서 오늘의 요일이 포함되어 있으면 true를 반환한다") + void WEEKLY_주기에서_오늘의_요일이_포함되어_있으면_true를_반환한다() { + // given + List activeDays = List.of(CycleDetail.MON, CycleDetail.TUE, CycleDetail.WED, CycleDetail.THU, + CycleDetail.FRI, CycleDetail.SAT, CycleDetail.SUN); + + // when + Boolean result = ChallengeBlockStatusUtil.isChallengeBlockActiveToday(Cycle.WEEKLY, activeDays); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("WEEKLY 주기에서 오늘의 요일이 포함되지 않으면 false를 반환한다") + void WEEKLY_주기에서_오늘의_요일이_포함되지_않으면_비활성_상태여야_한다() { + // given + List activeDays = List.of(); + + // when + Boolean result = ChallengeBlockStatusUtil.isChallengeBlockActiveToday(Cycle.WEEKLY, activeDays); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("MONTHLY 주기에서 오늘의 날짜가 포함되어 있으면 true를 반환한다.") + void MONTHLY_주기에서_오늘의_날짜가_포함되어_있으면_true를_반환한다() { + // given + List activeDays = List.of( + CycleDetail.FIRST, CycleDetail.SECOND, CycleDetail.THIRD, CycleDetail.FOURTH, CycleDetail.FIFTH, + CycleDetail.SIXTH, CycleDetail.SEVENTH, CycleDetail.EIGHTH, CycleDetail.NINTH, CycleDetail.TENTH, + CycleDetail.ELEVENTH, CycleDetail.TWELFTH, CycleDetail.THIRTEENTH, CycleDetail.FOURTEENTH, + CycleDetail.FIFTEENTH, + CycleDetail.SIXTEENTH, CycleDetail.SEVENTEENTH, CycleDetail.EIGHTEENTH, CycleDetail.NINETEENTH, + CycleDetail.TWENTIETH, + CycleDetail.TWENTY_FIRST, CycleDetail.TWENTY_SECOND, CycleDetail.TWENTY_THIRD, + CycleDetail.TWENTY_FOURTH, CycleDetail.TWENTY_FIFTH, + CycleDetail.TWENTY_SIXTH, CycleDetail.TWENTY_SEVENTH, CycleDetail.TWENTY_EIGHTH, + CycleDetail.TWENTY_NINTH, CycleDetail.THIRTIETH, + CycleDetail.THIRTY_FIRST); + + // when + Boolean result = ChallengeBlockStatusUtil.isChallengeBlockActiveToday(Cycle.MONTHLY, activeDays); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("MONTHLY 주기에서 오늘의 날짜가 포함되지 않으면 false를 반환한다") + void MONTHLY_주기에서_오늘의_날짜가_포함되지_않으면_false를_반환한다() { + // given + List activeDays = List.of(); + + // when + Boolean result = ChallengeBlockStatusUtil.isChallengeBlockActiveToday(Cycle.MONTHLY, activeDays); + + // then + assertThat(result).isFalse(); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/converter/CycleDetailsConverterTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/converter/CycleDetailsConverterTest.java new file mode 100644 index 00000000..0e377afe --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/converter/CycleDetailsConverterTest.java @@ -0,0 +1,66 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.converter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.challenge.domain.CycleDetail; +import shop.kkeujeok.kkeujeokbackend.challenge.exception.InvalidCycleDetailsConversionException; + +class CycleDetailsConverterTest { + + private CycleDetailsConverter converter; + private ObjectMapper mapper; + + @BeforeEach + void setUp() { + converter = new CycleDetailsConverter(); + mapper = new ObjectMapper(); + } + + @Test + @DisplayName("유효한 리스트를 JSON 문자열로 변환할 수 있다") + void 유효한_리스트를_JSON_문자열로_변환할_수_있다() throws JsonProcessingException { + // given + List cycleDetails = Arrays.asList(CycleDetail.MON, CycleDetail.TUE, CycleDetail.WED); + + // when + String json = converter.convertToDatabaseColumn(cycleDetails); + + // then + String expectedJson = mapper.writeValueAsString(cycleDetails); + assertThat(json).isEqualTo(expectedJson); + } + + @Test + @DisplayName("유효한 JSON 문자열을 리스트로 변환할 수 있다") + void 유효한_JSON_문자열을_리스트로_변환할_수_있다() throws JsonProcessingException { + // given + List expectedList = Arrays.asList(CycleDetail.MON, CycleDetail.TUE, CycleDetail.WED); + String jsonString = mapper.writeValueAsString(expectedList); + + // when + List result = converter.convertToEntityAttribute(jsonString); + + // then + assertThat(result).isEqualTo(expectedList); + } + + @Test + @DisplayName("유효하지 않은 JSON 문자열을 변환하려 할 때 예외가 발생한다") + void 유효하지_않은_JSON_문자열을_변환하려_할_때_예외가_발생한다() { + // given + String invalidJsonString = "invalid json"; + + // when & then + assertThatThrownBy(() -> converter.convertToEntityAttribute(invalidJsonString)) + .isInstanceOf(InvalidCycleDetailsConversionException.class) + .hasMessageContaining("JSON 문자열을 List로 변환하는 중 오류가 발생했습니다."); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/ChallengeTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/ChallengeTest.java new file mode 100644 index 00000000..8d7977e4 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/challenge/domain/ChallengeTest.java @@ -0,0 +1,152 @@ +package shop.kkeujeok.kkeujeokbackend.challenge.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.Role; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +class ChallengeTest { + private Challenge challenge; + + @BeforeEach + void setUp() { + Member member = Member.builder() + .status(Status.ACTIVE) + .email("kkeujeok@gmail.com") + .name("김동균") + .picture("프로필 사진") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("동동") + .build(); + + challenge = Challenge.builder() + .status(Status.ACTIVE) + .title("제목") + .contents("내용") + .cycleDetails(List.of(CycleDetail.MON, CycleDetail.TUE)) + .startDate(LocalDate.now()) + .endDate(LocalDate.now().plusDays(30)) + .representImage("대표 사진") + .member(member) + .build(); + } + + @Test + @DisplayName("챌린지의 모든 필드를 수정합니다.") + void 챌린지_수정() { + // given + String updateTitle = "수정된 제목"; + String updateContents = "수정된 내용"; + List updateCycleDetails = List.of(CycleDetail.WED, CycleDetail.THU); + LocalDate updateStartDate = LocalDate.now().plusDays(1); + LocalDate updateEndDate = LocalDate.now().plusDays(31); + String updateRepresentImage = "수정된 대표 사진"; + + // when + challenge.update(updateTitle, updateContents, updateCycleDetails, updateStartDate, updateEndDate, + updateRepresentImage); + + // then + assertAll(() -> { + assertThat(challenge.getTitle()).isEqualTo(updateTitle); + assertThat(challenge.getContents()).isEqualTo(updateContents); + assertThat(challenge.getCycleDetails()).isEqualTo(updateCycleDetails); + assertThat(challenge.getStartDate()).isEqualTo(updateStartDate); + assertThat(challenge.getEndDate()).isEqualTo(updateEndDate); + assertThat(challenge.getRepresentImage()).isEqualTo(updateRepresentImage); + }); + } + + @Test + @DisplayName("챌린지의 제목만 수정합니다.") + void 챌린지_제목_수정() { + // given + String updateTitle = "수정된 제목"; + + // when + challenge.update(updateTitle, challenge.getContents(), challenge.getCycleDetails(), challenge.getStartDate(), + challenge.getEndDate(), challenge.getRepresentImage()); + + // then + assertAll(() -> { + assertThat(challenge.getTitle()).isEqualTo(updateTitle); + assertThat(challenge.getContents()).isEqualTo("내용"); + assertThat(challenge.getCycleDetails()).isEqualTo(List.of(CycleDetail.MON, CycleDetail.TUE)); + assertThat(challenge.getStartDate()).isEqualTo(LocalDate.now()); + assertThat(challenge.getEndDate()).isEqualTo(LocalDate.now().plusDays(30)); + assertThat(challenge.getRepresentImage()).isEqualTo("대표 사진"); + }); + } + + @Test + @DisplayName("챌린지의 내용만 수정합니다.") + void 챌린지_내용_수정() { + // given + String updateContents = "수정된 내용"; + + // when + challenge.update(challenge.getTitle(), updateContents, challenge.getCycleDetails(), challenge.getStartDate(), + challenge.getEndDate(), challenge.getRepresentImage()); + + // then + assertAll(() -> { + assertThat(challenge.getTitle()).isEqualTo("제목"); + assertThat(challenge.getContents()).isEqualTo(updateContents); + assertThat(challenge.getCycleDetails()).isEqualTo(List.of(CycleDetail.MON, CycleDetail.TUE)); + assertThat(challenge.getStartDate()).isEqualTo(LocalDate.now()); + assertThat(challenge.getEndDate()).isEqualTo(LocalDate.now().plusDays(30)); + assertThat(challenge.getRepresentImage()).isEqualTo("대표 사진"); + }); + } + + @Test + @DisplayName("챌린지의 마감일만 수정합니다.") + void 챌린지_마감일_수정() { + // given + LocalDate updateEndDate = LocalDate.now().plusDays(40); + + // when + challenge.update(challenge.getTitle(), challenge.getContents(), challenge.getCycleDetails(), + challenge.getStartDate(), updateEndDate, challenge.getRepresentImage()); + + // then + assertAll(() -> { + assertThat(challenge.getTitle()).isEqualTo("제목"); + assertThat(challenge.getContents()).isEqualTo("내용"); + assertThat(challenge.getCycleDetails()).isEqualTo(List.of(CycleDetail.MON, CycleDetail.TUE)); + assertThat(challenge.getStartDate()).isEqualTo(LocalDate.now()); + assertThat(challenge.getEndDate()).isEqualTo(updateEndDate); + assertThat(challenge.getRepresentImage()).isEqualTo("대표 사진"); + }); + } + + @Test + @DisplayName("챌지의 상태를 수정합니다.") + void 챌린지_상태_수정() { + // given + assertThat(challenge.getStatus()).isEqualTo(Status.ACTIVE); + + // when + challenge.updateStatus(); + + // then + assertThat(challenge.getStatus()).isEqualTo(Status.DELETED); + + // when + challenge.updateStatus(); + + // then + assertThat(challenge.getStatus()).isEqualTo(Status.ACTIVE); + } +} + diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/common/annotation/ControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/common/annotation/ControllerTest.java new file mode 100644 index 00000000..7d6eb289 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/common/annotation/ControllerTest.java @@ -0,0 +1,95 @@ +package shop.kkeujeok.kkeujeokbackend.common.annotation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import shop.kkeujeok.kkeujeokbackend.auth.api.AuthController; +import shop.kkeujeok.kkeujeokbackend.auth.application.AuthMemberService; +import shop.kkeujeok.kkeujeokbackend.auth.application.AuthService; +import shop.kkeujeok.kkeujeokbackend.auth.application.AuthServiceFactory; +import shop.kkeujeok.kkeujeokbackend.auth.application.TokenService; +import shop.kkeujeok.kkeujeokbackend.block.api.BlockController; +import shop.kkeujeok.kkeujeokbackend.block.application.BlockService; +import shop.kkeujeok.kkeujeokbackend.challenge.api.ChallengeController; +import shop.kkeujeok.kkeujeokbackend.challenge.application.ChallengeService; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.application.PersonalDashboardService; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.application.TeamDashboardService; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application.DocumentService; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application.FileService; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; +import shop.kkeujeok.kkeujeokbackend.member.api.MemberControllerTest; +import shop.kkeujeok.kkeujeokbackend.member.mypage.application.MyPageService; +import shop.kkeujeok.kkeujeokbackend.member.nickname.application.NicknameService; +import shop.kkeujeok.kkeujeokbackend.notification.api.NotificationController; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; +import shop.kkeujeok.kkeujeokbackend.notification.util.SseEmitterManager; + +@AutoConfigureRestDocs +@WebMvcTest({ + BlockController.class, + AuthController.class, + ChallengeController.class, + MemberControllerTest.class, + NotificationController.class +}) +@ExtendWith(RestDocumentationExtension.class) +@ActiveProfiles("test") +public abstract class ControllerTest { + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + @MockBean + protected BlockService blockService; + + @MockBean + protected PersonalDashboardService personalDashboardService; + + @MockBean + protected TeamDashboardService teamDashboardService; + + @MockBean + protected TokenProvider tokenProvider; + + @MockBean + protected AuthServiceFactory authServiceFactory; + + @MockBean + protected AuthMemberService authMemberService; + + @MockBean + protected TokenService tokenService; + + @MockBean + protected AuthService authService; + + @MockBean + protected ChallengeService challengeService; + + @MockBean + protected NicknameService nicknameService; + + @MockBean + protected MyPageService myPageService; + + @MockBean + protected FileService fileService; + + @MockBean + protected DocumentService documentService; + + @MockBean + protected NotificationService notificationService; + + @MockBean + protected SseEmitterManager sseEmitterManager; +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/PersonalDashboardControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/PersonalDashboardControllerTest.java new file mode 100644 index 00000000..3c2695c7 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/api/PersonalDashboardControllerTest.java @@ -0,0 +1,320 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardCategoriesResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +class PersonalDashboardControllerTest extends ControllerTest { + + private Member member; + private PersonalDashboard personalDashboard; + private PersonalDashboardSaveReqDto personalDashboardSaveReqDto; + private PersonalDashboardUpdateReqDto personalDashboardUpdateReqDto; + + @InjectMocks + private PersonalDashboardController personalDashboardController; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + personalDashboardSaveReqDto = new PersonalDashboardSaveReqDto("title", "description", false, "category"); + personalDashboardUpdateReqDto = new PersonalDashboardUpdateReqDto("updateTitle", "updateDescription", true, + "updateCategory"); + personalDashboard = personalDashboardSaveReqDto.toEntity(member); + + ReflectionTestUtils.setField(personalDashboard, "id", 1L); + ReflectionTestUtils.setField(member, "id", 1L); + ReflectionTestUtils.setField(personalDashboard, "member", member); + + personalDashboardController = new PersonalDashboardController(personalDashboardService); + + mockMvc = MockMvcBuilders.standaloneSetup(personalDashboardController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()) + .build(); + + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn("email"); + } + + @DisplayName("POST 개인 대시보드 저장 컨트롤러 로직 확인") + @Test + void 개인_대시보드_저장() throws Exception { + // given + PersonalDashboardInfoResDto response = PersonalDashboardInfoResDto.of(member, personalDashboard); + given(personalDashboardService.save(anyString(), any(PersonalDashboardSaveReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/dashboards/personal/") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(personalDashboardSaveReqDto))) + .andDo(print()) + .andDo(document("dashboard/personal/save", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + requestFields( + fieldWithPath("title").description("개인 대시보드 제목"), + fieldWithPath("description").description("개인 대시보드 설명"), + fieldWithPath("isPublic").description("개인 대시보드 공개 범위"), + fieldWithPath("category").description("개인 대시보드 카테고리") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.dashboardId").description("대시보드 아이디"), + fieldWithPath("data.myId").description("내 아이디"), + fieldWithPath("data.creatorId").description("개인 대시보드 생성자 아이디"), + fieldWithPath("data.title").description("개인 대시보드 제목"), + fieldWithPath("data.description").description("개인 대시보드 설명"), + fieldWithPath("data.isPublic").description("개인 대시보드 공개 범위"), + fieldWithPath("data.category").description("개인 대시보드 카테고리"), + fieldWithPath("data.blockProgress").description("개인 대시보드의 완료된 블록 진행률") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("PATCH 개인 대시보드 수정 컨트롤러 로직 확인") + @Test + void 개인_대시보드_수정() throws Exception { + // given + personalDashboard.update(personalDashboardUpdateReqDto.title(), + personalDashboardUpdateReqDto.description(), + personalDashboardUpdateReqDto.isPublic(), + personalDashboardUpdateReqDto.category()); + PersonalDashboardInfoResDto response = PersonalDashboardInfoResDto.of(member, personalDashboard); + given(personalDashboardService.update(anyString(), anyLong(), + any(PersonalDashboardUpdateReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(patch("/api/dashboards/personal/{dashboardId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(personalDashboardUpdateReqDto))) + .andDo(print()) + .andDo(document("dashboard/personal/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("대시보드 ID") + ), + requestFields( + fieldWithPath("title").description("개인 대시보드 제목"), + fieldWithPath("description").description("개인 대시보드 설명"), + fieldWithPath("isPublic").description("개인 대시보드 공개 범위"), + fieldWithPath("category").description("개인 대시보드 카테고리") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.dashboardId").description("대시보드 아이디"), + fieldWithPath("data.myId").description("내 아이디"), + fieldWithPath("data.creatorId").description("개인 대시보드 생성자 아이디"), + fieldWithPath("data.title").description("개인 대시보드 제목"), + fieldWithPath("data.description").description("개인 대시보드 설명"), + fieldWithPath("data.isPublic").description("개인 대시보드 공개 범위"), + fieldWithPath("data.category").description("개인 대시보드 카테고리"), + fieldWithPath("data.blockProgress").description("개인 대시보드의 완료된 블록 진행률") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 개인 대시보드를 전체 조회합니다.") + @Test + void 개인_대시보드_전체_조회() throws Exception { + // given + PersonalDashboardListResDto response = PersonalDashboardListResDto.of( + Collections.singletonList(PersonalDashboardInfoResDto.of(member, personalDashboard)) + ); + + given(personalDashboardService.findForPersonalDashboard(anyString())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/dashboards/personal/") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/personal/findForPersonalDashboard", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.personalDashboardListResDto[].dashboardId") + .description("대시보드 아이디"), + fieldWithPath("data.personalDashboardListResDto[].myId") + .description("내 아이디"), + fieldWithPath("data.personalDashboardListResDto[].creatorId") + .description("개인 대시보드 생성자 아이디"), + fieldWithPath("data.personalDashboardListResDto[].title") + .description("개인 대시보드 제목"), + fieldWithPath("data.personalDashboardListResDto[].description") + .description("개인 대시보드 설명"), + fieldWithPath("data.personalDashboardListResDto[].isPublic") + .description("개인 대시보드 공개 범위"), + fieldWithPath("data.personalDashboardListResDto[].category") + .description("개인 대시보드 카테고리"), + fieldWithPath("data.personalDashboardListResDto[].blockProgress") + .description("개인 대시보드의 완료된 블록 진행률") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 개인 대시보드를 상세봅니다.") + @Test + void 개인_대시보드_상세보기() throws Exception { + // given + PersonalDashboardInfoResDto response = PersonalDashboardInfoResDto.of(member, personalDashboard); + + given(personalDashboardService.findById(anyString(), anyLong())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/dashboards/personal/{dashboardId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/personal/findById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("대시보드 아이디") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.dashboardId").description("대시보드 아이디"), + fieldWithPath("data.myId").description("내 아이디"), + fieldWithPath("data.creatorId").description("개인 대시보드 생성자 아이디"), + fieldWithPath("data.title").description("개인 대시보드 제목"), + fieldWithPath("data.description").description("개인 대시보드 설명"), + fieldWithPath("data.isPublic").description("개인 대시보드 공개 범위"), + fieldWithPath("data.category").description("개인 대시보드 카테고리"), + fieldWithPath("data.blockProgress").description("개인 대시보드의 완료된 블록 진행률") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 사용자 개인 대시보드들의 카테고리를 불러옵니다.") + @Test + void 개인_대시보드_카테고리_조회() throws Exception { + // given + PersonalDashboardCategoriesResDto response = PersonalDashboardCategoriesResDto.from(List.of("category")); + + given(personalDashboardService.findForPersonalDashboardByCategories(anyString())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/dashboards/personal/categories") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/personal/categories", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.categories[]").description("개인 대시보드 카테고리") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("DELETE 개인 대시보드를 논리적으로 삭제하고 복구합니다.") + @Test + void 개인_대시보드_삭제() throws Exception { + // given + doNothing().when(personalDashboardService).delete(anyString(), anyLong()); + + // when & then + mockMvc.perform(delete("/api/dashboards/personal/{dashboardId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/personal/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("개인 대시보드 ID") + + ) + )) + .andExpect(status().isOk()); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/application/PersonalDashboardServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/application/PersonalDashboardServiceTest.java new file mode 100644 index 00000000..6006f86a --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/application/PersonalDashboardServiceTest.java @@ -0,0 +1,238 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.request.PersonalDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardCategoriesResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.api.dto.response.PersonalDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.repository.PersonalDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.exception.DashboardAccessDeniedException; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +@ExtendWith(MockitoExtension.class) +class PersonalDashboardServiceTest { + + @Mock + private PersonalDashboardRepository personalDashboardRepository; + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private PersonalDashboardService personalDashboardService; + + private Member member; + private PersonalDashboard personalDashboard; + private PersonalDashboard deletePersonalDashboard; + private PersonalDashboardSaveReqDto personalDashboardSaveReqDto; + private PersonalDashboardUpdateReqDto personalDashboardUpdateReqDto; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.ofNullable(member)); + + personalDashboardSaveReqDto = new PersonalDashboardSaveReqDto("title", "description", false, "category"); + personalDashboardUpdateReqDto = new PersonalDashboardUpdateReqDto( + "updateTitle", + "updateDescription", + true, + "updateCategory"); + + personalDashboard = PersonalDashboard.builder() + .title(personalDashboardSaveReqDto.title()) + .description(personalDashboardSaveReqDto.description()) + .isPublic(personalDashboardSaveReqDto.isPublic()) + .category(personalDashboardSaveReqDto.category()) + .member(member) + .build(); + + deletePersonalDashboard = PersonalDashboard.builder() + .title(personalDashboardSaveReqDto.title()) + .description(personalDashboardSaveReqDto.description()) + .isPublic(personalDashboardSaveReqDto.isPublic()) + .category(personalDashboardSaveReqDto.category()) + .member(member) + .build(); + deletePersonalDashboard.statusUpdate(); + } + + @DisplayName("개인 대시보드를 저장합니다.") + @Test + void 개인_대시보드_저장() { + // given + when(personalDashboardRepository.save(any(PersonalDashboard.class))).thenReturn(personalDashboard); + + // when + PersonalDashboardInfoResDto result = personalDashboardService.save( + member.getEmail(), + personalDashboardSaveReqDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("title"); + assertThat(result.description()).isEqualTo("description"); + assertThat(result.isPublic()).isEqualTo(false); + assertThat(result.category()).isEqualTo("category"); + }); + } + + @DisplayName("개인 대시보드를 수정합니다.") + @Test + void 개인_대시보드_수정() { + // given + Long dashboardId = 1L; + when(personalDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(personalDashboard)); + + // when + PersonalDashboardInfoResDto result = personalDashboardService.update( + member.getEmail(), + dashboardId, + personalDashboardUpdateReqDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("updateTitle"); + assertThat(result.description()).isEqualTo("updateDescription"); + assertThat(result.isPublic()).isEqualTo(true); + assertThat(result.category()).isEqualTo("updateCategory"); + }); + } + + @DisplayName("생성자가 아닌 사용자가 개인 대시보드를 수정하는데 실패합니다. (DashboardAccessDeniedException 발생)") + @Test + void 개인_대시보드_수정_실패() { + // given + Long dashboardId = 1L; + String unauthorizedEmail = "unauthorizedEmail@email.com"; + + Member unauthorizedMember = Member.builder() + .email(unauthorizedEmail) + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + when(memberRepository.findByEmail(unauthorizedEmail)).thenReturn(Optional.of(unauthorizedMember)); + when(personalDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(personalDashboard)); + + // when & then + assertThatThrownBy( + () -> personalDashboardService.update(unauthorizedEmail, dashboardId, personalDashboardUpdateReqDto) + ).isInstanceOf(DashboardAccessDeniedException.class); + } + + @DisplayName("개인 대시보드를 삭제합니다.") + @Test + void 개인_대시보드_삭제() { + // given + Long dashboardId = 1L; + when(personalDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(personalDashboard)); + + // when + personalDashboardService.delete(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(personalDashboard.getStatus()).isEqualTo(Status.DELETED); + }); + } + + @DisplayName("개인 대시보드를 전체 조회합니다.") + @Test + void 개인_대시보드_전체_조회() { + // given + when(personalDashboardRepository.findForPersonalDashboard(any(Member.class))) + .thenReturn(List.of(personalDashboard)); + + // when + PersonalDashboardListResDto result = personalDashboardService.findForPersonalDashboard(member.getEmail()); + + // then + assertThat(result).isNotNull(); + assertThat(result.personalDashboardListResDto()).hasSize(1); + } + + @DisplayName("개인 대시보드를 상세 봅니다.") + @Test + void 개인_대시보드_상세보기() { + // given + Long dashboardId = 1L; + when(personalDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(personalDashboard)); + + // when + PersonalDashboardInfoResDto result = personalDashboardService.findById(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("title"); + assertThat(result.description()).isEqualTo("description"); + assertThat(result.isPublic()).isEqualTo(false); + assertThat(result.category()).isEqualTo("category"); + assertThat(result.blockProgress()).isEqualTo(0.0); + }); + } + + @DisplayName("개인 대시보드 카테고리를 조회합니다.") + @Test + void 개인_대시보드_카테고리_조회() { + // given + List categories = List.of("category"); + when(personalDashboardRepository.findForPersonalDashboardByCategory(any(Member.class))).thenReturn(categories); + + // when + PersonalDashboardCategoriesResDto result = personalDashboardService.findForPersonalDashboardByCategories( + member.getEmail()); + + // then + assertThat(result.categories()).hasSize(1); + assertThat(result.categories().get(0)).isEqualTo("category"); + } + + @DisplayName("삭제되었던 개인 대시보드를 복구합니다.") + @Test + void 개인_대시보드_복구() { + // given + Long dashboardId = 1L; + when(personalDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(deletePersonalDashboard)); + + // when + personalDashboardService.delete(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(deletePersonalDashboard.getStatus()).isEqualTo(Status.ACTIVE); + }); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/PersonalDashboardTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/PersonalDashboardTest.java new file mode 100644 index 00000000..335ce6c1 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/PersonalDashboardTest.java @@ -0,0 +1,65 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +class PersonalDashboardTest { + + private PersonalDashboard personalDashboard; + + @BeforeEach + void setUp() { + personalDashboard = PersonalDashboard.builder() + .title("title") + .description("description") + .isPublic(false) + .category("category") + .build(); + } + + @DisplayName("개인 대시보드의 모든 값을 수정합니다.") + @Test + void 개인_대시보드_수정() { + // given + String updateTitle = "updateTitle"; + String updateDescription = "updateDescription"; + boolean updateIsPublic = true; + String updateCategory = "category"; + + // when + personalDashboard.update(updateTitle, updateDescription, updateIsPublic, updateCategory); + + // then + assertAll(() -> { + assertThat(personalDashboard.getTitle()).isEqualTo(updateTitle); + assertThat(personalDashboard.getDescription()).isEqualTo(updateDescription); + assertThat(personalDashboard.isPublic()).isEqualTo(updateIsPublic); + assertThat(personalDashboard.getCategory()).isEqualTo(updateCategory); + }); + } + + @DisplayName("개인 대시보드 논리 삭제 상태를 수정합니다.") + @Test + void 개인_대시보드_상태_수정() { + // given + assertThat(personalDashboard.getStatus()).isEqualTo(Status.ACTIVE); + + // when + personalDashboard.statusUpdate(); + + // then + assertThat(personalDashboard.getStatus()).isEqualTo(Status.DELETED); + + // when + personalDashboard.statusUpdate(); + + // then + assertThat(personalDashboard.getStatus()).isEqualTo(Status.ACTIVE); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/repository/PersonalDashboardRepositoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/repository/PersonalDashboardRepositoryTest.java new file mode 100644 index 00000000..cc6f7564 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/personal/domain/repository/PersonalDashboardRepositoryTest.java @@ -0,0 +1,121 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.offset; + +import java.util.List; +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 shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.domain.PersonalDashboard; +import shop.kkeujeok.kkeujeokbackend.global.config.JpaAuditingConfig; +import shop.kkeujeok.kkeujeokbackend.global.config.QuerydslConfig; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +@DataJpaTest +@Import({JpaAuditingConfig.class, QuerydslConfig.class}) +@ActiveProfiles("test") +class PersonalDashboardRepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BlockRepository blockRepository; + + @Autowired + private PersonalDashboardRepository personalDashboardRepository; + + private Member member; + private PersonalDashboard personalDashboard; + private Block block1; + private Block block2; + private Block block3; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + memberRepository.save(member); + + personalDashboard = PersonalDashboard.builder() + .member(member) + .title("title") + .description("description") + .isPublic(false) + .category("category") + .build(); + + personalDashboardRepository.save(personalDashboard); + + block1 = Block.builder() + .title("title1") + .contents("contents1") + .progress(Progress.COMPLETED) + .deadLine("2024.07.27 11:03") + .dashboard(personalDashboard) + .build(); + + block2 = Block.builder() + .title("title2") + .contents("contents2") + .progress(Progress.NOT_STARTED) + .deadLine("2024.07.27 11:04") + .dashboard(personalDashboard) + .build(); + + block3 = Block.builder() + .title("title3") + .contents("contents3") + .progress(Progress.COMPLETED) + .deadLine("2024.07.27 11:05") + .dashboard(personalDashboard) + .build(); + + blockRepository.save(block1); + blockRepository.save(block2); + blockRepository.save(block3); + } + + @DisplayName("개인 대시보드를 전체 조회합니다.") + @Test + void 개인_대시보드_전체_조회() { + // given + + // when + List result = personalDashboardRepository.findForPersonalDashboard(member); + + // then + assertThat(result.size()).isEqualTo(1); + assertThat(result.get(0).getTitle()).isEqualTo("title"); + assertThat(result.get(0).getMember()).isEqualTo(member); + } + + @DisplayName("대시보드의 완료된 블록 비율을 계산합니다.") + @Test + void 개인_대시보드_완료_블록_비율_계산() { + // when + double completionPercentage = personalDashboardRepository + .calculateCompletionPercentage(personalDashboard.getId()); + + // then + assertThat(completionPercentage).isEqualTo(66.67, offset(0.01)); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/TeamDashboardControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/TeamDashboardControllerTest.java new file mode 100644 index 00000000..0dc9f1d2 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/api/TeamDashboardControllerTest.java @@ -0,0 +1,371 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.SearchMemberListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +class TeamDashboardControllerTest extends ControllerTest { + + private Member member; + private Member joinMember; + private TeamDashboard teamDashboard; + private TeamDashboardSaveReqDto teamDashboardSaveReqDto; + private TeamDashboardUpdateReqDto teamDashboardUpdateReqDto; + + @InjectMocks + private TeamDashboardController teamDashboardController; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + joinMember = Member.builder() + .email("joinEmail") + .name("joinName") + .nickname("joinNickname") + .socialType(SocialType.GOOGLE) + .introduction("joinIntroduction") + .picture("joinPicture") + .build(); + + teamDashboardSaveReqDto = new TeamDashboardSaveReqDto("title", "description"); + teamDashboardUpdateReqDto = new TeamDashboardUpdateReqDto("updateTitle", "updateDescription"); + teamDashboard = teamDashboardSaveReqDto.toEntity(member); + + ReflectionTestUtils.setField(teamDashboard, "id", 1L); + ReflectionTestUtils.setField(member, "id", 1L); + ReflectionTestUtils.setField(teamDashboard, "member", member); + + teamDashboardController = new TeamDashboardController(teamDashboardService); + + mockMvc = MockMvcBuilders.standaloneSetup(teamDashboardController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()) + .build(); + + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn("email"); + } + + @DisplayName("POST 팀 대시보드 저장 컨트롤러 로직 확인") + @Test + void 팀_대시보드_저장() throws Exception { + // given + TeamDashboardInfoResDto response = TeamDashboardInfoResDto.of(member, teamDashboard); + given(teamDashboardService.save(anyString(), any(TeamDashboardSaveReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/dashboards/team/") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(teamDashboardSaveReqDto))) + .andDo(print()) + .andDo(document("dashboard/team/save", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + requestFields( + fieldWithPath("title").description("팀 대시보드 제목"), + fieldWithPath("description").description("팀 대시보드 설명") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.dashboardId").description("대시보드 아이디"), + fieldWithPath("data.myId").description("내 아이디"), + fieldWithPath("data.creatorId").description("팀 대시보드 생성자 아이디"), + fieldWithPath("data.title").description("팀 대시보드 제목"), + fieldWithPath("data.description").description("팀 대시보드 설명"), + fieldWithPath("data.blockProgress").description("팀 대시보드의 완료된 블록 진행률"), + fieldWithPath("data.joinMembers").description("팀 대시보드에 참여한 사용자") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("PATCH 팀 대시보드 수정 컨트롤러 로직 확인") + @Test + void 팀_대시보드_수정() throws Exception { + // given + teamDashboard.update(teamDashboardUpdateReqDto.title(), teamDashboardUpdateReqDto.description()); + TeamDashboardInfoResDto response = TeamDashboardInfoResDto.of(member, teamDashboard); + given(teamDashboardService.update(anyString(), anyLong(), any(TeamDashboardUpdateReqDto.class))) + .willReturn(response); + + // when & then + mockMvc.perform(patch("/api/dashboards/team/{dashboardId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(teamDashboardUpdateReqDto))) + .andDo(print()) + .andDo(document("dashboard/team/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("대시보드 ID") + ), + requestFields( + fieldWithPath("title").description("개인 대시보드 제목"), + fieldWithPath("description").description("개인 대시보드 설명") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.dashboardId").description("대시보드 아이디"), + fieldWithPath("data.myId").description("내 아이디"), + fieldWithPath("data.creatorId").description("팀 대시보드 생성자 아이디"), + fieldWithPath("data.title").description("팀 대시보드 제목"), + fieldWithPath("data.description").description("팀 대시보드 설명"), + fieldWithPath("data.blockProgress").description("팀 대시보드의 완료된 블록 진행률"), + fieldWithPath("data.joinMembers").description("팀 대시보드에 참여한 사용자") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 팀 대시보드를 전체 조회합니다.") + @Test + void 팀_대시보드_전체_조회() throws Exception { + // given + TeamDashboardListResDto response = TeamDashboardListResDto.from( + Collections.singletonList(TeamDashboardInfoResDto.of(member, teamDashboard)) + ); + + given(teamDashboardService.findForTeamDashboard(anyString())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/dashboards/team/") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/team/findForTeamDashboard", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.teamDashboardInfoResDto[].dashboardId") + .description("대시보드 아이디"), + fieldWithPath("data.teamDashboardInfoResDto[].myId") + .description("내 아이디"), + fieldWithPath("data.teamDashboardInfoResDto[].creatorId") + .description("팀 대시보드 생성자 아이디"), + fieldWithPath("data.teamDashboardInfoResDto[].title") + .description("팀 대시보드 제목"), + fieldWithPath("data.teamDashboardInfoResDto[].description") + .description("팀 대시보드 설명"), + fieldWithPath("data.teamDashboardInfoResDto[].blockProgress") + .description("팀 대시보드의 완료된 블록 진행률"), + fieldWithPath("data.teamDashboardInfoResDto[].joinMembers") + .description("팀 대시보드에 참여한 사용자"), + fieldWithPath("data.pageInfoResDto") + .description("페이지 정보가 없습니다.") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 팀 대시보드를 상세봅니다.") + @Test + void 팀_대시보드_상세보기() throws Exception { + // given + TeamDashboardInfoResDto response = TeamDashboardInfoResDto.of(member, teamDashboard); + + given(teamDashboardService.findById(anyString(), anyLong())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/dashboards/team/{dashboardId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/team/findById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("대시보드 아이디") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.dashboardId").description("대시보드 아이디"), + fieldWithPath("data.myId").description("내 아이디"), + fieldWithPath("data.creatorId").description("팀 대시보드 생성자 아이디"), + fieldWithPath("data.title").description("팀 대시보드 제목"), + fieldWithPath("data.description").description("팀 대시보드 설명"), + fieldWithPath("data.blockProgress").description("팀 대시보드의 완료된 블록 진행률"), + fieldWithPath("data.joinMembers").description("팀 대시보드에 참여한 사용자") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("DELETE 팀 대시보드를 논리적으로 삭제하고 복구합니다.") + @Test + void 팀_대시보드_삭제() throws Exception { + // given + doNothing().when(teamDashboardService).delete(anyString(), anyLong()); + + // when & then + mockMvc.perform(delete("/api/dashboards/team/{dashboardId}", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/team/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("팀 대시보드 ID") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("POST 팀 대시보드 초대를 수락합니다.") + @Test + void 팀_대시보드_초대_수락() throws Exception { + // given + doNothing().when(teamDashboardService).joinTeam(anyString(), anyLong()); + + // when & then + mockMvc.perform(post("/api/dashboards/team/{dashboardId}/join", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/team/join", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("팀 대시보드 ID") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("POST 참여한 팀 대시보드를 탈퇴합니다.") + @Test + void 팀_대시보드_탈퇴() throws Exception { + // given + doNothing().when(teamDashboardService).leaveTeam(anyString(), anyLong()); + + // when & then + mockMvc.perform(post("/api/dashboards/team/{dashboardId}/leave", 1L) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/team/leave", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + pathParameters( + parameterWithName("dashboardId").description("팀 대시보드 ID") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 팀원 초대 리스트를 조회합니다.") + @Test + void 팀_초대_멤버_조회() throws Exception { + // given + SearchMemberListResDto response = SearchMemberListResDto.from(List.of(joinMember)); + + given(teamDashboardService.searchMembers(anyString())).willReturn(response); + + // when & then + mockMvc.perform(get(String.format("/api/dashboards/team/search?query=%s", "joinEmail")) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("dashboard/team/search", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + queryParameters( + parameterWithName("query").description("검색 조건(이메일, 닉네임#고유번호)") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.searchMembers[].id").description("대시보드 아이디"), + fieldWithPath("data.searchMembers[].picture").description("내 아이디"), + fieldWithPath("data.searchMembers[].email").description("팀 대시보드 생성자 아이디") + ) + )) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/application/TeamDashboardServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/application/TeamDashboardServiceTest.java new file mode 100644 index 00000000..7de09ecc --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/application/TeamDashboardServiceTest.java @@ -0,0 +1,275 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.dashboard.personal.exception.DashboardAccessDeniedException; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardSaveReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.request.TeamDashboardUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.SearchMemberListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository.TeamDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +@ExtendWith(MockitoExtension.class) +class TeamDashboardServiceTest { + + @Mock + private TeamDashboardRepository teamDashboardRepository; + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private TeamDashboardService teamDashboardService; + + private Member member; + private TeamDashboard teamDashboard; + private TeamDashboard deleteTeamDashboard; + private TeamDashboardSaveReqDto teamDashboardSaveReqDto; + private TeamDashboardUpdateReqDto teamDashboardUpdateReqDto; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + lenient().when(memberRepository.findByEmail(anyString())).thenReturn(Optional.ofNullable(member)); + + teamDashboardSaveReqDto = new TeamDashboardSaveReqDto("title", "description"); + teamDashboardUpdateReqDto = new TeamDashboardUpdateReqDto("updateTitle", "updateDescription"); + + teamDashboard = TeamDashboard.builder() + .title(teamDashboardSaveReqDto.title()) + .description(teamDashboardSaveReqDto.description()) + .member(member) + .build(); + + deleteTeamDashboard = TeamDashboard.builder() + .title(teamDashboardSaveReqDto.title()) + .description(teamDashboardSaveReqDto.description()) + .member(member) + .build(); + deleteTeamDashboard.statusUpdate(); + } + + @DisplayName("팀 대시보드를 저장합니다.") + @Test + void 팀_대시보드_저장() { + // given + when(teamDashboardRepository.save(any(TeamDashboard.class))).thenReturn(teamDashboard); + + // when + TeamDashboardInfoResDto result = teamDashboardService.save(member.getEmail(), teamDashboardSaveReqDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("title"); + assertThat(result.description()).isEqualTo("description"); + }); + } + + @DisplayName("팀 대시보드를 수정합니다.") + @Test + void 팀_대시보드_수정() { + // given + Long dashboardId = 1L; + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(teamDashboard)); + + // when + TeamDashboardInfoResDto result = teamDashboardService.update( + member.getEmail(), + dashboardId, + teamDashboardUpdateReqDto); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("updateTitle"); + assertThat(result.description()).isEqualTo("updateDescription"); + }); + } + + @DisplayName("생성자가 아닌 사용자가 팀 대시보드를 수정하는데 실패합니다. (DashboardAccessDeniedException 발생)") + @Test + void 팀_대시보드_수정_실패() { + // given + Long dashboardId = 1L; + String unauthorizedEmail = "unauthorizedEmail@email.com"; + + Member unauthorizedMember = Member.builder() + .email(unauthorizedEmail) + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + when(memberRepository.findByEmail(unauthorizedEmail)).thenReturn(Optional.of(unauthorizedMember)); + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(teamDashboard)); + + // when & then + assertThatThrownBy( + () -> teamDashboardService.update(unauthorizedEmail, dashboardId, teamDashboardUpdateReqDto) + ).isInstanceOf(DashboardAccessDeniedException.class); + } + + @DisplayName("팀 대시보드를 삭제합니다.") + @Test + void 팀_대시보드_삭제() { + // given + Long dashboardId = 1L; + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(teamDashboard)); + + // when + teamDashboardService.delete(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(teamDashboard.getStatus()).isEqualTo(Status.DELETED); + }); + } + + @DisplayName("팀 대시보드를 전체 조회합니다.") + @Test + void 팀_대시보드_전체_조회() { + // given + Pageable pageable = PageRequest.of(0, 10); + Page teamDashboardPage = new PageImpl<>( + List.of(teamDashboard), + pageable, + 1); + + when(teamDashboardRepository.findForTeamDashboard(any(Member.class), any(Pageable.class))) + .thenReturn(teamDashboardPage); + + // when + TeamDashboardListResDto result = teamDashboardService. + findForTeamDashboard(member.getEmail(), pageable); + + // then + assertThat(result).isNotNull(); + assertThat(result.teamDashboardInfoResDto()).hasSize(1); + } + + @DisplayName("팀 대시보드를 상세 봅니다.") + @Test + void 팀_대시보드_상세보기() { + // given + Long dashboardId = 1L; + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(teamDashboard)); + + // when + TeamDashboardInfoResDto result = teamDashboardService.findById(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("title"); + assertThat(result.description()).isEqualTo("description"); + assertThat(result.blockProgress()).isEqualTo(0.0); + }); + } + + @DisplayName("삭제되었던 팀 대시보드를 복구합니다.") + @Test + void 팀_대시보드_복구() { + // given + Long dashboardId = 1L; + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(deleteTeamDashboard)); + + // when + teamDashboardService.delete(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(deleteTeamDashboard.getStatus()).isEqualTo(Status.ACTIVE); + }); + } + + @DisplayName("팀 대시보드에 참여합니다") + @Test + void 팀_대시보드_참여() { + // given + Long dashboardId = 1L; + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(teamDashboard)); + + // when + teamDashboardService.joinTeam(member.getEmail(), dashboardId); + TeamDashboardInfoResDto result = teamDashboardService.findById(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("title"); + assertThat(result.description()).isEqualTo("description"); + assertThat(result.blockProgress()).isEqualTo(0.0); + assertThat(result.joinMembers().size()).isEqualTo(1); + }); + } + + @DisplayName("팀 대시보드를 탈퇴합니다.") + @Test + void 팀_대시보드_탈퇴() { + // given + Long dashboardId = 1L; + when(teamDashboardRepository.findById(dashboardId)).thenReturn(Optional.of(teamDashboard)); + + // when + teamDashboardService.joinTeam(member.getEmail(), dashboardId); + teamDashboardService.leaveTeam(member.getEmail(), dashboardId); + TeamDashboardInfoResDto result = teamDashboardService.findById(member.getEmail(), dashboardId); + + // then + assertAll(() -> { + assertThat(result.title()).isEqualTo("title"); + assertThat(result.description()).isEqualTo("description"); + assertThat(result.blockProgress()).isEqualTo(0.0); + assertThat(result.joinMembers().size()).isEqualTo(0); + }); + } + + @DisplayName("팀원 초대 리스트를 조회합니다.") + @Test + void 팀_초대_멤버_조회() { + // given + String query = "email"; + when(teamDashboardRepository.findForMembersByQuery(query)).thenReturn(List.of(member)); + + // when + SearchMemberListResDto result = teamDashboardService.searchMembers(query); + + // then + assertAll(() -> { + assertThat(result.searchMembers().size()).isEqualTo(1); + }); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboardTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboardTest.java new file mode 100644 index 00000000..ef976b8e --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/TeamDashboardTest.java @@ -0,0 +1,98 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; + +class TeamDashboardTest { + + private Member member; + private TeamDashboard teamDashboard; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + teamDashboard = TeamDashboard.builder() + .title("title") + .description("description") + .build(); + } + + @DisplayName("팀 대시보드의 모든 값을 수정합니다.") + @Test + void 팀_대시보드_수정() { + // given + String updateTitle = "updateTitle"; + String updateDescription = "updateDescription"; + + // when + teamDashboard.update(updateTitle, updateDescription); + + // then + assertAll(() -> { + assertThat(teamDashboard.getTitle()).isEqualTo(updateTitle); + assertThat(teamDashboard.getDescription()).isEqualTo(updateDescription); + }); + } + + @DisplayName("팀 대시보드 논리 삭제 상태를 수정합니다.") + @Test + void 팀_대시보드_상태_수정() { + // given + assertThat(teamDashboard.getStatus()).isEqualTo(Status.ACTIVE); + + // when + teamDashboard.statusUpdate(); + + // then + assertThat(teamDashboard.getStatus()).isEqualTo(Status.DELETED); + + // when + teamDashboard.statusUpdate(); + + // then + assertThat(teamDashboard.getStatus()).isEqualTo(Status.ACTIVE); + } + + @DisplayName("팀 대시보드에 참가합니다.") + @Test + void 팀_대시보드_참가() { + // given + assertTrue(teamDashboard.getTeamDashboardMemberMappings().isEmpty()); + + // when + teamDashboard.addMember(member); + + // then + assertThat(teamDashboard.getTeamDashboardMemberMappings().size()).isEqualTo(1); + } + + @DisplayName("팀 대시보드 탈퇴") + @Test + void 팀_대시보드_탈퇴() { + // given + teamDashboard.addMember(member); + assertThat(teamDashboard.getTeamDashboardMemberMappings().size()).isEqualTo(1); + + // when + teamDashboard.removeMember(member); + + // then + assertThat(teamDashboard.getTeamDashboardMemberMappings().size()).isEqualTo(0); + } + +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/repository/TeamDashboardRepositoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/repository/TeamDashboardRepositoryTest.java new file mode 100644 index 00000000..ab909bcd --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/team/domain/repository/TeamDashboardRepositoryTest.java @@ -0,0 +1,141 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.offset; + +import java.util.List; +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.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import shop.kkeujeok.kkeujeokbackend.block.domain.Block; +import shop.kkeujeok.kkeujeokbackend.block.domain.Progress; +import shop.kkeujeok.kkeujeokbackend.block.domain.repository.BlockRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.global.config.JpaAuditingConfig; +import shop.kkeujeok.kkeujeokbackend.global.config.QuerydslConfig; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; + +@DataJpaTest +@Import({JpaAuditingConfig.class, QuerydslConfig.class}) +@ActiveProfiles("test") +class TeamDashboardRepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BlockRepository blockRepository; + + @Autowired + private TeamDashboardRepository teamDashboardRepository; + + private Member member; + private TeamDashboard teamDashboard; + private Block block1; + private Block block2; + private Block block3; + + @BeforeEach + void setUp() { + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .tag("#0000") + .build(); + + memberRepository.save(member); + + teamDashboard = TeamDashboard.builder() + .member(member) + .title("title") + .description("description") + .build(); + + teamDashboardRepository.save(teamDashboard); + + block1 = Block.builder() + .title("title1") + .contents("contents1") + .progress(Progress.COMPLETED) + .deadLine("2024.07.27 11:03") + .dashboard(teamDashboard) + .build(); + + block2 = Block.builder() + .title("title2") + .contents("contents2") + .progress(Progress.NOT_STARTED) + .deadLine("2024.07.27 11:04") + .dashboard(teamDashboard) + .build(); + + block3 = Block.builder() + .title("title3") + .contents("contents3") + .progress(Progress.COMPLETED) + .deadLine("2024.07.27 11:05") + .dashboard(teamDashboard) + .build(); + + blockRepository.save(block1); + blockRepository.save(block2); + blockRepository.save(block3); + } + + @DisplayName("팀 대시보드를 전체 조회합니다.") + @Test + void 팀_대시보드_전체_조회() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = teamDashboardRepository.findForTeamDashboard(member, pageable); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).getTitle()).isEqualTo("title"); + assertThat(result.getContent().get(0).getMember()).isEqualTo(member); + } + + @DisplayName("팀 대시보드를 생성할 때 초대 멤버 리스트를 조회합니다.") + @Test + void 초대_멤버_조회() { + // given + String query1 = "email"; + String query2 = "nickname#0000"; + + // when + List result1 = teamDashboardRepository.findForMembersByQuery(query1); + List result2 = teamDashboardRepository.findForMembersByQuery(query2); + + // then + assertThat(result1.size()).isEqualTo(1); + assertThat(result1.get(0).getName()).isEqualTo("name"); + + assertThat(result2.size()).isEqualTo(1); + } + + @DisplayName("대시보드의 완료된 블록 비율을 계산합니다.") + @Test + void 팀_대시보드_완료_블록_비율_계산() { + // when + double completionPercentage = teamDashboardRepository + .calculateCompletionPercentage(teamDashboard.getId()); + + // then + assertThat(completionPercentage).isEqualTo(66.67, offset(0.01)); + } +} \ No newline at end of file diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/DocumentControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/DocumentControllerTest.java new file mode 100644 index 00000000..73ddce11 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/DocumentControllerTest.java @@ -0,0 +1,212 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; + +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; + +import java.util.Collections; +import java.util.List; + +class DocumentControllerTest extends ControllerTest { + + @InjectMocks + DocumentController documentController; + + private Document document; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + documentController = new DocumentController(documentService); + + mockMvc = MockMvcBuilders.standaloneSetup(documentController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()) + .build(); + + document = Document.builder() + .title("DocumentTitle") + .build(); + + ReflectionTestUtils.setField(document, "id", 1L); + } + + @DisplayName("POST 팀 문서 저장 컨트롤러 로직 확인") + @Test + void POST_팀_문서_저장_컨트롤러_로직_확인() throws Exception { + // given + DocumentInfoReqDto request = new DocumentInfoReqDto(1L, "DocumentTitle"); + DocumentInfoResDto response = new DocumentInfoResDto(1L, "DocumentTitle"); + + given(documentService.save(any(DocumentInfoReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/documents") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("document/save", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + requestFields( + fieldWithPath("teamDashboardId").description("팀 대시보드 ID"), + fieldWithPath("title").description("문서 제목") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.documentId").description("문서 ID"), + fieldWithPath("data.title").description("문서 제목") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("PATCH 팀 문서 수정 컨트롤러 로직 확인") + @Test + void PATCH_팀_문서_수정_컨트롤러_로직_확인() throws Exception { + // given + Long documentId = 1L; + DocumentUpdateReqDto request = new DocumentUpdateReqDto("Updated Document Title"); + DocumentInfoResDto response = new DocumentInfoResDto(documentId, "Updated Document Title"); + + given(documentService.update(anyLong(), any(DocumentUpdateReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(patch("/api/documents/{documentId}", documentId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("document/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("documentId").description("문서 ID") + ), + requestFields( + fieldWithPath("title").description("문서 제목") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.documentId").description("문서 ID"), + fieldWithPath("data.title").description("문서 제목") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 팀 문서 리스트 조회 컨트롤러 로직 확인") + @Test + void GET_팀_문서_리스트_조회_컨트롤러_로직_확인() throws Exception { + // given + PageRequest pageRequest = PageRequest.of(0, 10); + Page documentPage = new PageImpl<>(List.of(document)); + + DocumentListResDto response = new DocumentListResDto( + Collections.singletonList(new DocumentInfoResDto(1L, "DocumentTitle")), + PageInfoResDto.from(documentPage) + ); + + given(documentService.findDocumentByTeamDashboardId(anyLong(), any(PageRequest.class))).willReturn(response); + + // when & then + mockMvc.perform(get("/api/documents") + .param("teamDashboardId", "1") + .param("page", "0") + .param("size", "10") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("document/findForDocumentByTeamDashboardId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("teamDashboardId").description("팀 대시보드 ID"), + parameterWithName("page").description("페이지 번호"), + parameterWithName("size").description("페이지 크기") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.documentInfoResDtos[].documentId").description("문서 ID"), + fieldWithPath("data.documentInfoResDtos[].title").description("문서 제목"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지 번호"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지 수"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템 수") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("DELETE 팀 문서 삭제 컨트롤러 로직 확인") + @Test + void DELETE_팀_문서_삭제_컨트롤러_로직_확인() throws Exception { + // given + Long documentId = 1L; + doNothing().when(documentService).delete(anyLong()); + + // when & then + mockMvc.perform(delete("/api/documents/{documentId}", documentId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("document/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("documentId").description("문서 ID") + ) + )) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/FileControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/FileControllerTest.java new file mode 100644 index 00000000..067fb75c --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/api/FileControllerTest.java @@ -0,0 +1,268 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.requestFields; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.FileInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application.FileService; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; + +class FileControllerTest extends ControllerTest{ + + @InjectMocks + FileController fileController; + private Document document; + private File file; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + fileController = new FileController(fileService); + + mockMvc = MockMvcBuilders.standaloneSetup(fileController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()) + .build(); + + document = Document.builder() + .title("DocumentTitle") + .build(); + + ReflectionTestUtils.setField(document, "id", 1L); + + file = File.builder() + .email("email") + .title("FileTitle") + .content("FileContent") + .document(document) + .build(); + + when(tokenProvider.getUserEmailFromToken(any())).thenReturn("email"); + } + + @DisplayName("POST 파일 저장 컨트롤러 로직 확인") + @Test + void POST_파일_저장_컨트롤러_로직_확인() throws Exception { + // given + FileInfoReqDto request = new FileInfoReqDto(1L, "email", "title", "content"); + FileInfoResDto response = new FileInfoResDto(1L, "title", "content", "email"); + + given(fileService.save(anyString(), any(FileInfoReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/files/") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("file/save", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + requestFields( + fieldWithPath("documentId").description("문서 ID"), + fieldWithPath("email").description("파일 생성자 이메일"), + fieldWithPath("title").description("파일 제목"), + fieldWithPath("content").description("파일 내용") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.fileId").description("파일 ID"), + fieldWithPath("data.title").description("파일 제목"), + fieldWithPath("data.content").description("파일 내용"), + fieldWithPath("data.email").description("파일 생성자 이메일") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("PATCH 파일 수정 컨트롤러 로직 확인") + @Test + void PATCH_파일_수정_컨트롤러_로직_확인() throws Exception { + // given + Long fileId = 1L; + FileInfoReqDto request = new FileInfoReqDto(1L, "email", "updatedTitle", "updatedContent"); + FileInfoResDto response = new FileInfoResDto(fileId, "updatedTitle", "updatedContent", "email"); + + given(fileService.update(anyLong(), any(FileInfoReqDto.class))).willReturn(response); + + // when & then + mockMvc.perform(patch("/api/files/{fileId}", fileId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("file/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("fileId").description("파일 ID") + ), + requestFields( + fieldWithPath("documentId").description("문서 ID"), + fieldWithPath("email").description("파일 생성자 이메일"), + fieldWithPath("title").description("파일 제목"), + fieldWithPath("content").description("파일 내용") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.fileId").description("파일 ID"), + fieldWithPath("data.title").description("파일 제목"), + fieldWithPath("data.content").description("파일 내용"), + fieldWithPath("data.email").description("파일 생성자 이메일") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 파일 리스트 조회 컨트롤러 로직 확인") + @Test + void GET_파일_리스트_조회_컨트롤러_로직_확인() throws Exception { + // given + PageRequest pageRequest = PageRequest.of(0, 10); + Page filePage = new PageImpl<>(List.of(file)); + + FileListResDto response = new FileListResDto( + Collections.singletonList(new FileInfoResDto(1L, "title", "content", "email")), + PageInfoResDto.from(filePage) + ); + + given(fileService.findForFile(anyLong(), any(PageRequest.class))).willReturn(response); + + // when & then + mockMvc.perform(get("/api/files") + .param("documentId", "1") + .param("page", "0") + .param("size", "10") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("file/findForFile", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("documentId").description("문서 ID"), + parameterWithName("page").description("페이지 번호"), + parameterWithName("size").description("페이지 크기") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.fileInfoResDto[].fileId").description("파일 ID"), + fieldWithPath("data.fileInfoResDto[].title").description("파일 제목"), + fieldWithPath("data.fileInfoResDto[].content").description("파일 내용"), + fieldWithPath("data.fileInfoResDto[].email").description("파일 생성자 이메일"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지 번호"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지 수"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 아이템 수") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("GET 파일 상세보기 컨트롤러 로직 확인") + @Test + void GET_파일_상세보기_컨트롤러_로직_확인() throws Exception { + // given + Long fileId = 1L; + FileInfoResDto response = new FileInfoResDto(fileId, "title", "content", "email"); + + given(fileService.findById(anyLong())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/files/{fileId}", fileId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("file/findById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("fileId").description("파일 ID") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.fileId").description("파일 ID"), + fieldWithPath("data.title").description("파일 제목"), + fieldWithPath("data.content").description("파일 내용"), + fieldWithPath("data.email").description("파일 생성자 이메일") + ) + )) + .andExpect(status().isOk()); + } + + @DisplayName("DELETE 파일 삭제 컨트롤러 로직 확인") + @Test + void DELETE_파일_삭제_컨트롤러_로직_확인() throws Exception { + // given + Long fileId = 1L; + doNothing().when(fileService).delete(anyLong()); + + // when & then + mockMvc.perform(delete("/api/files/{fileId}", fileId) + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andDo(document("file/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("fileId").description("파일 ID") + ) + )) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/DocumentServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/DocumentServiceTest.java new file mode 100644 index 00000000..9b5f78a4 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/DocumentServiceTest.java @@ -0,0 +1,180 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository.TeamDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.DocumentUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.DocumentListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository.DocumentRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.DocumentNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.TeamDashboardNotFoundException; + +import java.util.List; +import java.util.Optional; + +class DocumentServiceTest { + + @Mock + private DocumentRepository documentRepository; + + @Mock + private TeamDashboardRepository teamDashboardRepository; + + @InjectMocks + private DocumentService documentService; + + private Document document; + private TeamDashboard teamDashboard; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + teamDashboard = TeamDashboard.builder() + .title("Team Dashboard Title") + .description("Team Dashboard Description") + .build(); + + ReflectionTestUtils.setField(teamDashboard, "id", 1L); + + document = Document.builder() + .title("Document Title") + .teamDashboard(teamDashboard) + .build(); + + ReflectionTestUtils.setField(document, "id", 1L); + } + + @DisplayName("문서를 생성합니다.") + @Test + void 문서를_생성합니다() { + // given + DocumentInfoReqDto documentInfoReqDto = new DocumentInfoReqDto(1L, "Document Title"); + + when(teamDashboardRepository.findById(anyLong())).thenReturn(Optional.of(teamDashboard)); + when(documentRepository.save(any(Document.class))).thenReturn(document); + + // when + DocumentInfoResDto result = documentService.save(documentInfoReqDto); + + // then + assertThat(result.title()).isEqualTo(document.getTitle()); + + verify(teamDashboardRepository).findById(documentInfoReqDto.teamDashboardId()); + verify(documentRepository).save(any(Document.class)); + } + + @DisplayName("존재하지 않는 팀 대시보드에 문서를 생성 시 예외가 발생합니다.") + @Test + void 존재하지_않는_팀_대시보드에_문서를_생성_시_예외가_발생합니다() { + // given + DocumentInfoReqDto documentInfoReqDto = new DocumentInfoReqDto(999L, "Document Title"); + + when(teamDashboardRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> documentService.save(documentInfoReqDto)) + .isInstanceOf(TeamDashboardNotFoundException.class); + + verify(teamDashboardRepository).findById(documentInfoReqDto.teamDashboardId()); + verify(documentRepository, never()).save(any(Document.class)); + } + + @DisplayName("문서를 성공적으로 수정합니다.") + @Test + void 문서를_성공적으로_수정합니다() { + // given + DocumentUpdateReqDto documentUpdateReqDto = new DocumentUpdateReqDto("Updated Document Title"); + + when(documentRepository.findById(anyLong())).thenReturn(Optional.of(document)); + + // when + DocumentInfoResDto result = documentService.update(1L, documentUpdateReqDto); + + // then + assertThat(result.title()).isEqualTo(documentUpdateReqDto.title()); + + verify(documentRepository).findById(1L); + } + + @DisplayName("존재하지 않는 문서를 수정 시 예외가 발생합니다.") + @Test + void 존재하지_않는_문서를_수정_시_예외가_발생합니다() { + // given + DocumentUpdateReqDto documentUpdateReqDto = new DocumentUpdateReqDto("Updated Document Title"); + + when(documentRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> documentService.update(1L, documentUpdateReqDto)) + .isInstanceOf(DocumentNotFoundException.class); + + verify(documentRepository).findById(1L); + } + + @DisplayName("팀 대시보드 ID로 문서를 성공적으로 조회합니다.") + @Test + void 팀_대시보드_ID로_문서를_성공적으로_조회합니다() { + // given + Pageable pageable = PageRequest.of(0, 10); + Page documents = new PageImpl<>(List.of(document)); + + when(documentRepository.findByDocumentWithTeamDashboard(anyLong(), any(Pageable.class))).thenReturn(documents); + + // when + DocumentListResDto result = documentService.findDocumentByTeamDashboardId(1L, pageable); + + // then + assertThat(result.documentInfoResDtos().size()).isEqualTo(1); + assertThat(result.documentInfoResDtos().get(0).title()).isEqualTo(document.getTitle()); + + verify(documentRepository).findByDocumentWithTeamDashboard(1L, pageable); + } + + @DisplayName("문서를 논리적으로 삭제합니다.") + @Test + void 문서를_논리적으로_삭제합니다() { + // given + when(documentRepository.findById(anyLong())).thenReturn(Optional.of(document)); + + // when + documentService.delete(1L); + + // then + assertThat(document.getStatus()).isEqualTo(shop.kkeujeok.kkeujeokbackend.global.entity.Status.DELETED); + + verify(documentRepository).findById(1L); + } + + @DisplayName("존재하지 않는 문서를 삭제 시 예외가 발생합니다.") + @Test + void 존재하지_않는_문서를_삭제_시_예외가_발생합니다() { + // given + when(documentRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> documentService.delete(1L)) + .isInstanceOf(DocumentNotFoundException.class); + + verify(documentRepository).findById(1L); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/FileServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/FileServiceTest.java new file mode 100644 index 00000000..933af8dd --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/application/FileServiceTest.java @@ -0,0 +1,210 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.request.FileInfoReqDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileInfoResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.api.dto.response.FileListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository.DocumentRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository.FileRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.DocumentNotFoundException; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.exception.FileNotFoundException; + +import java.util.List; +import java.util.Optional; + +class FileServiceTest { + + @Mock + private DocumentRepository documentRepository; + + @Mock + private FileRepository fileRepository; + + @InjectMocks + private FileService fileService; + + private File file; + + private Document document; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + document = Document.builder() + .title("DocumentTitle") + .build(); + + ReflectionTestUtils.setField(document, "id", 1L); + + file = File.builder() + .email("email") + .title("FileTitle") + .content("FileContent") + .document(document) + .build(); + } + + @DisplayName("파일을 생성합니다.") + @Test + void 파일을_생성합니다() { + // given + FileInfoReqDto fileInfoReqDto = new FileInfoReqDto(document.getId(), "email", "FileTitle", "FileContent"); + + when(documentRepository.findById(anyLong())).thenReturn(Optional.of(document)); + when(fileRepository.save(any(File.class))).thenReturn(file); + + // when + FileInfoResDto result = fileService.save("New email", fileInfoReqDto); + + // then + assertThat(result.title()).isEqualTo(file.getTitle()); + assertThat(result.content()).isEqualTo(file.getContent()); + + verify(documentRepository).findById(fileInfoReqDto.documentId()); + verify(fileRepository).save(any(File.class)); + } + + @DisplayName("존재하지 않는 문서에 파일 생성 시 예외가 발생합니다.") + @Test + void 존재하지_않는_문서에_파일_생성_시_예외가_발생합니다() { + // given + FileInfoReqDto fileInfoReqDto = new FileInfoReqDto(999L, "email", "New File", "New Content"); + + when(documentRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> fileService.save("noExistEmail", fileInfoReqDto)) + .isInstanceOf(DocumentNotFoundException.class); + + verify(documentRepository).findById(fileInfoReqDto.documentId()); + verify(fileRepository, never()).save(any(File.class)); + } + + @DisplayName("파일을 성공적으로 수정합니다.") + @Test + void 파일을_성공적으로_수정합니다() { + // given + FileInfoReqDto fileInfoReqDto = new FileInfoReqDto(document.getId(), "email", "Updated Title", "Updated Content"); + + when(fileRepository.findById(anyLong())).thenReturn(Optional.of(file)); + + // when + FileInfoResDto result = fileService.update(1L, fileInfoReqDto); + + // then + assertThat(result.title()).isEqualTo(fileInfoReqDto.title()); + assertThat(result.content()).isEqualTo(fileInfoReqDto.content()); + + verify(fileRepository).findById(1L); + } + + @DisplayName("존재하지 않는 파일 수정 시 예외가 발생합니다.") + @Test + void 존재하지_않는_파일_수정_시_예외가_발생합니다() { + // given + FileInfoReqDto fileInfoReqDto = new FileInfoReqDto(document.getId(), "email", "Updated Title", "Updated Content"); + + when(fileRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> fileService.update(1L, fileInfoReqDto)) + .isInstanceOf(FileNotFoundException.class); + + verify(fileRepository).findById(1L); + } + + @DisplayName("파일 리스트를 성공적으로 조회합니다.") + @Test + void 파일_리스트를_성공적으로_조회합니다() { + // given + Pageable pageable = PageRequest.of(0, 10); + Page files = new PageImpl<>(List.of(file)); + + when(fileRepository.findByFilesWithDocumentId(anyLong(), any(Pageable.class))).thenReturn(files); + + // when + FileListResDto result = fileService.findForFile(1L, pageable); + + // then + assertThat(result.fileInfoResDto().size()).isEqualTo(1); + assertThat(result.fileInfoResDto().get(0).title()).isEqualTo(file.getTitle()); + + verify(fileRepository).findByFilesWithDocumentId(1L, pageable); + } + + @DisplayName("파일을 성공적으로 조회합니다.") + @Test + void 파일을_성공적으로_조회합니다() { + // given + when(fileRepository.findById(anyLong())).thenReturn(Optional.of(file)); + + // when + FileInfoResDto result = fileService.findById(1L); + + // then + assertThat(result.title()).isEqualTo(file.getTitle()); + assertThat(result.content()).isEqualTo(file.getContent()); + + verify(fileRepository).findById(1L); + } + + @DisplayName("존재하지 않는 파일 조회 시 예외가 발생합니다.") + @Test + void 존재하지_않는_파일_조회_시_예외가_발생합니다() { + // given + when(fileRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> fileService.findById(1L)) + .isInstanceOf(FileNotFoundException.class); + + verify(fileRepository).findById(1L); + } + + @DisplayName("파일을 논리적으로 삭제합니다.") + @Test + void 파일을_논리적으로_삭제합니다() { + // given + when(fileRepository.findById(anyLong())).thenReturn(Optional.of(file)); + + // when + fileService.delete(1L); + + // then + assertThat(file.getStatus()).isEqualTo(shop.kkeujeok.kkeujeokbackend.global.entity.Status.DELETED); + + verify(fileRepository).findById(1L); + } + + @DisplayName("존재하지 않는 파일 삭제 시 예외가 발생합니다.") + @Test + void 존재하지_않는_파일_삭제_시_예외가_발생합니다() { + // given + when(fileRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> fileService.delete(1L)) + .isInstanceOf(FileNotFoundException.class); + + verify(fileRepository).findById(1L); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/DocumentTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/DocumentTest.java new file mode 100644 index 00000000..39f26f64 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/DocumentTest.java @@ -0,0 +1,60 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +class DocumentTest { + + private TeamDashboard teamDashboard; + private Document document; + + @BeforeEach + void setUp() { + teamDashboard = TeamDashboard.builder() + .title("teamDashboardTitle") + .description("teamDashboardDescription") + .build(); + + document = Document.builder() + .title("documentTitle") + .teamDashboard(teamDashboard) + .build(); + } + + @DisplayName("문서의 모든 값을 수정합니다.") + @Test + void 문서의_모든_값을_수정합니다() { + // given + String updateTitle = "Updated Document Title"; + + // when + document.update(updateTitle); + + // then + assertThat(document.getTitle()).isEqualTo(updateTitle); + } + + @DisplayName("문서의 논리 삭제 상태를 수정합니다.") + @Test + void 문서의_논리_삭제_상태를_수정합니다() { + // given + assertThat(document.getStatus()).isEqualTo(Status.ACTIVE); + + // when + document.statusUpdate(); + + // then + assertThat(document.getStatus()).isEqualTo(Status.DELETED); + + // when + document.statusUpdate(); + + // then + assertThat(document.getStatus()).isEqualTo(Status.ACTIVE); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/FileTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/FileTest.java new file mode 100644 index 00000000..ab29b51b --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/FileTest.java @@ -0,0 +1,65 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; + +class FileTest { + + private Document document; + private File file; + + @BeforeEach + void setUp() { + document = Document.builder() + .title("documentTitle") + .build(); + + file = File.builder() + .email("email") + .title("fileTitle") + .content("content") + .document(document) + .build(); + } + + @DisplayName("파일의 모든 값을 수정합니다.") + @Test + void 파일_수정() { + // given + String updateTitle = "Updated Title"; + String updateContent = "Updated Content"; + + // when + file.update(updateTitle, updateContent); + + // then + assertAll(() -> { + assertThat(file.getTitle()).isEqualTo(updateTitle); + assertThat(file.getContent()).isEqualTo(updateContent); + }); + } + + @DisplayName("파일의 논리 삭제 상태를 수정합니다.") + @Test + void 파일의_논리_삭제_상태를_수정합니다() { + // given + assertThat(file.getStatus()).isEqualTo(Status.ACTIVE); + + // when + file.statusUpdate(); + + // then + assertThat(file.getStatus()).isEqualTo(Status.DELETED); + + // when + file.statusUpdate(); + + // then + assertThat(file.getStatus()).isEqualTo(Status.ACTIVE); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentRepositoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentRepositoryTest.java new file mode 100644 index 00000000..cf984f20 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/DocumentRepositoryTest.java @@ -0,0 +1,92 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +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.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.TeamDashboard; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.domain.repository.TeamDashboardRepository; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.global.config.JpaAuditingConfig; +import shop.kkeujeok.kkeujeokbackend.global.config.QuerydslConfig; + +@DataJpaTest +@Import({JpaAuditingConfig.class, QuerydslConfig.class}) +@ActiveProfiles("test") +class DocumentRepositoryTest { + + @Autowired + private DocumentRepository documentRepository; + + @Autowired + private TeamDashboardRepository teamDashboardRepository; + + private TeamDashboard teamDashboard; + private Document document1; + private Document document2; + private Document document3; + + @BeforeEach + void setUp() { + teamDashboard = TeamDashboard.builder() + .title("title") + .description("description") + .build(); + + document1 = Document.builder() + .title("Document Title 1") + .teamDashboard(teamDashboard) + .build(); + + document2 = Document.builder() + .title("Document Title 2") + .teamDashboard(teamDashboard) + .build(); + + document3 = Document.builder() + .title("Document Title 3") + .teamDashboard(teamDashboard) + .build(); + + teamDashboardRepository.save(teamDashboard); + documentRepository.save(document1); + documentRepository.save(document2); + documentRepository.save(document3); + } + + @DisplayName("팀 대시보드 ID로 문서를 전체 조회합니다.") + @Test + void 팀_대시보드_ID로_문서를_전체_조회합니다() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when + Page documents = documentRepository.findByDocumentWithTeamDashboard(teamDashboard.getId(), pageable); + + // then + assertThat(documents.getContent().size()).isEqualTo(3); + assertThat(documents.getContent()).extracting("teamDashboard.Id").containsOnly(teamDashboard.getId()); + } + + @DisplayName("문서를 논리 삭제 상태별로 전체 조회합니다.") + @Test + void 문서를_논리_삭제_상태별로_전체_조회합니다() { + // given + Pageable pageable = PageRequest.of(0, 10); + document1.statusUpdate(); + + // when + Page documents = documentRepository.findByDocumentWithTeamDashboard(teamDashboard.getId(), pageable); + + // then + assertThat(documents.getContent().size()).isEqualTo(2); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileRepositoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileRepositoryTest.java new file mode 100644 index 00000000..ccfc535b --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/dashboard/teamdocument/domain/repository/FileRepositoryTest.java @@ -0,0 +1,96 @@ +package shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +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.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.Document; +import shop.kkeujeok.kkeujeokbackend.dashboard.teamdocument.domain.File; +import shop.kkeujeok.kkeujeokbackend.global.config.JpaAuditingConfig; +import shop.kkeujeok.kkeujeokbackend.global.config.QuerydslConfig; + +@DataJpaTest +@Import({JpaAuditingConfig.class, QuerydslConfig.class}) +@ActiveProfiles("test") +class FileRepositoryTest { + + @Autowired + private FileRepository fileRepository; + + @Autowired + private DocumentRepository documentRepository; + + private Document document; + private File file1; + private File file2; + private File file3; + + @BeforeEach + void setUp() { + document = Document.builder() + .title("Document Title") + .build(); + + file1 = File.builder() + .email("email1") + .title("title1") + .content("content1") + .document(document) + .build(); + + file2 = File.builder() + .email("email2") + .title("title2") + .content("content2") + .document(document) + .build(); + + file3 = File.builder() + .email("email3") + .title("title3") + .content("content3") + .document(document) + .build(); + + documentRepository.save(document); + fileRepository.save(file1); + fileRepository.save(file2); + fileRepository.save(file3); + } + + @DisplayName("Document ID로 파일을 전체 조회합니다.") + @Test + void Document_ID로_파일을_전체_조회합니다() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when + Page files = fileRepository.findByFilesWithDocumentId(document.getId(), pageable); + + // then + assertThat(files.getContent().size()).isEqualTo(3); + assertThat(files.getContent()).extracting("document.id").containsOnly(document.getId()); + } + + @DisplayName("파일을 논리 삭제 상태별로 전체 조회합니다.") + @Test + void 파일을_논리_삭제_상태별로_전체_조회합니다() { + // given + Pageable pageable = PageRequest.of(0, 10); + file1.statusUpdate(); + + // when + Page files = fileRepository.findByFilesWithDocumentId(document.getId(), pageable); + + // then + assertThat(files.getContent().size()).isEqualTo(2); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/annotationresolver/CurrentUserEmailArgumentResolverTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/annotationresolver/CurrentUserEmailArgumentResolverTest.java new file mode 100644 index 00000000..2136f825 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/annotationresolver/CurrentUserEmailArgumentResolverTest.java @@ -0,0 +1,99 @@ +package shop.kkeujeok.kkeujeokbackend.global.annotationresolver; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.global.annotation.CurrentUserEmail; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class CurrentUserEmailArgumentResolverTest { + + @Mock + private TokenProvider tokenProvider; + + @Mock + private HttpServletRequest request; + + @Mock + private NativeWebRequest webRequest; + + @Mock + private MethodParameter methodParameter; + + @InjectMocks + private CurrentUserEmailArgumentResolver resolver; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + @DisplayName("어노테이션이 있을 때는 true를 반환하고, 없을 때는 false를 반환합니다.") + @Test + public void 어노테이션이_있을_때는_true를_반환하고_없을_때는_false를_반환합니다() { + when(methodParameter.getParameterAnnotation(CurrentUserEmail.class)).thenReturn(mock(CurrentUserEmail.class)); + assertTrue(resolver.supportsParameter(methodParameter)); + + when(methodParameter.getParameterAnnotation(CurrentUserEmail.class)).thenReturn(null); + assertFalse(resolver.supportsParameter(methodParameter)); + } + + @DisplayName("토큰을 통해 이메일을 추출합니다.") + @Test + public void 토큰을_통해_이메일을_추출합니다() { + String token = "Bearer someValidToken"; + String userEmail = "user@example.com"; + + when(webRequest.getNativeRequest()).thenReturn(request); + when(request.getHeader("Authorization")).thenReturn(token); + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn(userEmail); + + Object result = resolver.resolveArgument(methodParameter, + mock(ModelAndViewContainer.class), + webRequest, + mock(WebDataBinderFactory.class)); + + assertEquals(userEmail, result); + } + + @DisplayName("토큰이 없을 때 NULL을 호출합니다.") + @Test + public void 토큰이_없을_때_NULL을_호출합니다() { + when(webRequest.getNativeRequest()).thenReturn(request); + when(request.getHeader("Authorization")).thenReturn(null); + + Object result = resolver.resolveArgument(methodParameter, + mock(ModelAndViewContainer.class), + webRequest, + mock(WebDataBinderFactory.class)); + + assertNull(result); + } + + @DisplayName("토큰이 잘못됐을 때 NULL을 호출합니다.") + @Test + public void 토큰이_잘못됐을_때_NULL을_호출합니다() { + String token = "InvalidTokenFormat"; + + when(webRequest.getNativeRequest()).thenReturn(request); + when(request.getHeader("Authorization")).thenReturn(token); + + Object result = resolver.resolveArgument(methodParameter, + mock(ModelAndViewContainer.class), + webRequest, + mock(WebDataBinderFactory.class)); + + assertNull(result); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/filter/LoginCheckFilterTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/filter/LoginCheckFilterTest.java new file mode 100644 index 00000000..f494a4b6 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/filter/LoginCheckFilterTest.java @@ -0,0 +1,92 @@ +package shop.kkeujeok.kkeujeokbackend.global.filter; + +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import shop.kkeujeok.kkeujeokbackend.global.jwt.TokenProvider; + +import java.io.IOException; + +import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +public class LoginCheckFilterTest { + + @Mock(lenient = true) + private TokenProvider tokenProvider; // Mock으로 TokenProvider 주입 lenient = ture는 불필요한 스터빙에 대한 경고를 하지 않게 한다 + + @InjectMocks + private LoginCheckFilter loginCheckFilter; // InjectMocks로 필터 주입 + + @BeforeEach + public void setUp() { + loginCheckFilter = new LoginCheckFilter(tokenProvider); // 필터 인스턴스를 직접 초기화하여 Mock TokenProvider 주입 + } + + @DisplayName("access토큰으로 api 인가를 받을 수 있다.") + @Test + public void access토큰으로_api_인가를_받을_수_있다() throws IOException, ServletException { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer valid-token"); + request.setRequestURI("/inho"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain filterChain = new MockFilterChain(); + + when(tokenProvider.validateToken("valid-token")).thenReturn(true); + + loginCheckFilter.doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isNotEqualTo(HttpStatus.UNAUTHORIZED.value()); + } + +// 화이트리스트를 전부 열어 두었기 때문에 해당 테스트는 실패 (개발 끝나면 열 예정) +// @DisplayName("access토큰 없이는 api 인가를 받을 수 없다.") +// @Test +// public void access토큰_없이는_api_인가를_받을_수_없다() throws IOException, ServletException { +// MockHttpServletRequest request = new MockHttpServletRequest(); +// request.setRequestURI("/inho"); +// MockHttpServletResponse response = new MockHttpServletResponse(); +// MockFilterChain filterChain = new MockFilterChain(); +// +// loginCheckFilter.doFilter(request, response, filterChain); +// +// assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); +// } + + @DisplayName("화이트 리스트를 열어 두면 필터가 생략된다.") + @Test + public void 화이트_리스트를_열어_두면_필터가_생략된다() throws IOException, ServletException { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain filterChain = new MockFilterChain(); + + loginCheckFilter.doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isNotEqualTo(HttpStatus.UNAUTHORIZED.value()); + } + +// 화이트리스트를 전부 열어 두었기 때문에 해당 테스트는 실패 (개발 끝나면 열 예정) +// @DisplayName("화이트 리스트를 닫아 두면 필터가 작동한다.") +// @Test +// public void 화이트_리스트를_닫아_두면_필터가_작동한다() throws IOException, ServletException { +// MockHttpServletRequest request = new MockHttpServletRequest(); +// request.setRequestURI("/inho"); +// MockHttpServletResponse response = new MockHttpServletResponse(); +// MockFilterChain filterChain = new MockFilterChain(); +// +// loginCheckFilter.doFilter(request, response, filterChain); +// +// assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); +// } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProviderTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProviderTest.java new file mode 100644 index 00000000..c0d673e7 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/TokenProviderTest.java @@ -0,0 +1,94 @@ +package shop.kkeujeok.kkeujeokbackend.global.jwt; + + +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; +import shop.kkeujeok.kkeujeokbackend.global.jwt.api.dto.TokenDto; +import java.security.Key; +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +public class TokenProviderTest { + + private final String accessTokenExpireTime = "3600"; + private final String secret = "A".repeat(128); + + private TokenProvider tokenProvider; + + @BeforeEach + void init() { + Key key = Keys.secretKeyFor(SignatureAlgorithm.HS512); + tokenProvider = new TokenProvider(accessTokenExpireTime, "3600", secret, key); + tokenProvider.init(); + } + + @DisplayName("엑세스 토큰을 생성합니다.") + @Test + void 엑세스_토큰을_생성합니다() { + // given + String email = "inho@gmail.com"; + + // when + String actual = tokenProvider.generateAccessToken(email); + + // then + // 배열의 크기가 3인지 확인하는 테스트 코드 + assertThat(actual.split("\\.")).hasSize(3); + } + + @DisplayName("리프레시 토큰을 생성합니다.") + @Test + void 리프레시_토큰을_생성합니다() { + + // given, when + String actual = tokenProvider.generateRefreshToken(); + + // then + // 배열의 크기가 3인지 확인하는 테스트 코드 + assertThat(actual.split("\\.")).hasSize(3); + } + + @DisplayName("토큰들을 반환합니다.") + @Test + void 토큰들을_반환합니다() { + // given + String email = "inho@gmail.com"; + + // when + TokenDto actual = tokenProvider.generateToken(email); + + //then + assertThat(actual).isNotNull(); // 토큰이 null이 아닌지 확인 + } + + @DisplayName("리프레시 토큰으로 엑세스 토큰을 반환합니다.") + @Test + void 리프레시_토큰으로_엑세스_토큰을_반환합니다() { + // given + String refreshToken = "refreshToken"; + String email = "inho@gmail.com"; + + //when + TokenDto actual = tokenProvider.generateAccessTokenByRefreshToken(email, refreshToken); + + //then + assertThat(actual).isNotNull(); + } + + @DisplayName("토큰을 검증하여 유효하지 않으면 false를 반환합니다.") + @Test + void 토큰을_검증하여_유효하지_않으면_false를_반환합니다() { + // given + String malformedToken = "malformedToken"; + + // when + boolean result = tokenProvider.validateToken(malformedToken); + + // then + assertThat(result).isFalse(); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/TokenTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/TokenTest.java new file mode 100644 index 00000000..f7769d5d --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/TokenTest.java @@ -0,0 +1,30 @@ +package shop.kkeujeok.kkeujeokbackend.global.jwt.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +public class TokenTest { + @DisplayName("refresh token을 교체한다.") + @Test + void refresh_token을_교체한다() { + // given + Member 인호 = new Member(); + String refreshToken = "adasaegsfadasdasfgfgrgredksgdffa"; + Token oAuthToken = new Token(인호, refreshToken); + + String updatedRefreshToken = "dfgsbnskjglnafgkajfnakfjgngejlkrqgn"; + + // when + oAuthToken.refreshTokenUpdate(updatedRefreshToken); + + // then + assertThat(oAuthToken.getRefreshToken()).isEqualTo(updatedRefreshToken); + } + + +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/repository/TokenRepositoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/repository/TokenRepositoryTest.java new file mode 100644 index 00000000..d487ea94 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/jwt/domain/repository/TokenRepositoryTest.java @@ -0,0 +1,91 @@ +package shop.kkeujeok.kkeujeokbackend.global.jwt.domain.repository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import shop.kkeujeok.kkeujeokbackend.global.jwt.domain.Token; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TokenRepositoryTest { + + @Mock + private TokenRepository tokenRepository; + + private Member member; + private Token token; + + @BeforeEach + void setUp() { + member = Member.builder().email("test@example.com").build(); + token = Token.builder().member(member).refreshToken("refresh-token").build(); + } + + @DisplayName("member로 token이 존재하는지 확인한다.") + + @Test + void member로_token이_존재하는지_확인한다() { + // given + when(tokenRepository.existsByMember(any(Member.class))).thenReturn(true); + + // when + boolean exists = tokenRepository.existsByMember(member); + + // then + assertThat(exists).isTrue(); + } + + @DisplayName("member로 token을 찾는다.") + + @Test + void member로_token을_찾는다() { + // given + when(tokenRepository.findByMember(any(Member.class))).thenReturn(Optional.of(token)); + + // when + Optional foundToken = tokenRepository.findByMember(member); + + // then + assertThat(foundToken).isPresent(); + assertThat(foundToken.get().getMember()).isEqualTo(member); + } + + @DisplayName("refreshToken으로 token이 존재하는지 확인한다.") + + @Test + void refreshToken으로_token이_존재하는지_확인한다() { + // given + when(tokenRepository.existsByRefreshToken(anyString())).thenReturn(true); + + // when + boolean exists = tokenRepository.existsByRefreshToken("refresh-token"); + + // then + assertThat(exists).isTrue(); + } + + @DisplayName("refreshToken으로 token을 찾는다.") + + @Test + void refreshToken으로_token을_찾는다() { + // given + when(tokenRepository.findByRefreshToken(anyString())).thenReturn(Optional.of(token)); + + // when + Optional foundToken = tokenRepository.findByRefreshToken("refresh-token"); + + // then + assertThat(foundToken).isPresent(); + assertThat(foundToken.get().getRefreshToken()).isEqualTo("refresh-token"); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthServiceTest.java new file mode 100644 index 00000000..a8369ceb --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/GoogleAuthServiceTest.java @@ -0,0 +1,62 @@ +package shop.kkeujeok.kkeujeokbackend.global.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.client.RestTemplate; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +@ActiveProfiles("test") +public class GoogleAuthServiceTest { + + @Mock + private ObjectMapper objectMapper; + + @Mock + private RestTemplate restTemplate; + + @InjectMocks + private GoogleAuthService googleAuthService; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + googleAuthService = new GoogleAuthService(objectMapper, restTemplate); + } + + // getIdToken 테스트코드.. + + @DisplayName("올바르게 구글 소셜 정보가 넘어가는지 확인합니다.") + @Test + void 올바르게_구글_소셜_정보가_넘어가는지_확인합니다() { + String provider = "google"; + String actualProvider = googleAuthService.getProvider(); + + assertEquals(provider, actualProvider); + } + + @DisplayName("JWT 토큰을 사용하여 유저 정보를 반환합니다.") + @Test + void JWT_토큰을_사용하여_유저_정보를_반환합니다() throws Exception { + String token = "test.test.test"; + + String decodePayload = new String(Base64.getUrlDecoder().decode(token.split("\\.")[1]), StandardCharsets.UTF_8); + UserInfo userInfo = new UserInfo("email", "name", "picture", "nickname"); + + when(objectMapper.readValue(decodePayload, UserInfo.class)).thenReturn(userInfo); + + assertNotNull(googleAuthService.getUserInfo(token)); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthServiceTest.java new file mode 100644 index 00000000..12935210 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/oauth/KakaoAuthServiceTest.java @@ -0,0 +1,61 @@ +package shop.kkeujeok.kkeujeokbackend.global.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.client.RestTemplate; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.response.UserInfo; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +@ActiveProfiles("test") +public class KakaoAuthServiceTest { + @Mock + private ObjectMapper objectMapper; + + @Mock + private RestTemplate restTemplate; + + @InjectMocks + private KakaoAuthService kakaoAuthService; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + kakaoAuthService = new KakaoAuthService(objectMapper, restTemplate); + } + + // getIdToken 테스트코드.. + + @DisplayName("올바르게 카카오 소셜 정보가 넘어가는지 확인합니다.") + @Test + void 올바르게_카카오_소셜_정보가_넘어가는지_확인합니다() { + String provider = "kakao"; + String actualProvider = kakaoAuthService.getProvider(); + + assertEquals(provider, actualProvider); + } + + @DisplayName("JWT 토큰을 사용하여 유저 정보를 반환합니다.") + @Test + void JWT_토큰을_사용하여_유저_정보를_반환합니다() throws Exception { + String token = "test.test.test"; + + String decodePayload = new String(Base64.getUrlDecoder().decode(token.split("\\.")[1]), StandardCharsets.UTF_8); + UserInfo userInfo = new UserInfo("email", "name", "picture","nickname"); + + when(objectMapper.readValue(decodePayload, UserInfo.class)).thenReturn(userInfo); + + assertNotNull(kakaoAuthService.getUserInfo(token)); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/global/restdocs/RestDocsHandler.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/restdocs/RestDocsHandler.java new file mode 100644 index 00000000..5a01af5c --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/global/restdocs/RestDocsHandler.java @@ -0,0 +1,43 @@ +package shop.kkeujeok.kkeujeokbackend.global.restdocs; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; + +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.payload.PayloadDocumentation; +import org.springframework.restdocs.snippet.Snippet; + +public class RestDocsHandler { + public static RestDocumentationResultHandler createRestDocsHandler(String identifier, Snippet... snippets) { + return document(identifier, + preprocessRequest( + prettyPrint()), + preprocessResponse( + prettyPrint()), + snippets + ); + } + + public static RestDocumentationResultHandler createRestDocsHandlerWithFields( + String identifier, + Snippet requestFieldsSnippet, + Snippet responseFieldsSnippet) { + return document(identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFieldsSnippet, + responseFieldsSnippet + ); + } + + public static Snippet requestFields(FieldDescriptor... fields) { + return PayloadDocumentation.requestFields(fields); + } + + public static Snippet responseFields(FieldDescriptor... fields) { + return PayloadDocumentation.responseFields(fields); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/member/api/MemberControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/api/MemberControllerTest.java new file mode 100644 index 00000000..5da0d1d9 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/api/MemberControllerTest.java @@ -0,0 +1,137 @@ +package shop.kkeujeok.kkeujeokbackend.member.api; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.MyPageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.TeamDashboardsAndChallengesResDto; + +import java.util.ArrayList; +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.hamcrest.Matchers.is; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +public class MemberControllerTest extends ControllerTest { + + @InjectMocks + private MemberController memberController; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + memberController = new MemberController(myPageService); + + mockMvc = MockMvcBuilders.standaloneSetup(memberController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider), + new PageableHandlerMethodArgumentResolver()) // 추가 + .build(); + } + + @DisplayName("내 프로필 정보를 가져옵니다.") + @Test + void 내_프로필_정보를_가져옵니다() throws Exception { + MyPageInfoResDto myPageInfoResDto = new MyPageInfoResDto( + "picture", + "email", + "name", + "nickname", + SocialType.GOOGLE, + "introduction"); + + when(myPageService.findMyProfileByEmail(anyString())).thenReturn(myPageInfoResDto); + + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn("email"); + + mockMvc.perform(get("/api/members/mypage") + .header("Authorization", "Bearer valid-token")) + .andDo(print()) + .andDo(document("member/mypage", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.picture").description("회원 사진"), + fieldWithPath("data.email").description("회원 이메일"), + fieldWithPath("data.name").description("회원 이름"), + fieldWithPath("data.nickName").description("회원 닉네임"), + fieldWithPath("data.socialType").description("회원 소셜 타입"), + fieldWithPath("data.introduction").description("회원 소개") + ) + )) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message", is("내 프로필 정보"))) + .andExpect(jsonPath("$.data").exists()); + } + + @DisplayName("팀 대시보드와 챌린지 정보를 가져옵니다.") + @Test + void 팀_대시보드와_챌린지_정보를_가져옵니다() throws Exception { + TeamDashboardsAndChallengesResDto resDto = new TeamDashboardsAndChallengesResDto( + new TeamDashboardListResDto(new ArrayList<>(), new PageInfoResDto(0, 0, 0)), + new ChallengeListResDto(new ArrayList<>(), new PageInfoResDto(0, 0, 0)) + ); + + when(myPageService.findTeamDashboardsAndChallenges(anyString(), any())).thenReturn(resDto); + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn("email"); + + mockMvc.perform(get("/api/members/mypage/dashboard-challenges") + .header("Authorization", "Bearer valid-token")) + .andDo(print()) + .andDo(document("member/team-challenges", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("JWT 토큰") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.teamDashboardList.teamDashboardInfoResDto").description("팀 대시보드 정보 목록"), + fieldWithPath("data.teamDashboardList.pageInfoResDto.currentPage").description("현재 페이지 번호"), + fieldWithPath("data.teamDashboardList.pageInfoResDto.totalPages").description("총 페이지 수"), + fieldWithPath("data.teamDashboardList.pageInfoResDto.totalItems").description("총 항목 수"), + fieldWithPath("data.challengeList.challengeInfoResDto").description("챌린지 정보 목록"), + fieldWithPath("data.challengeList.pageInfoResDto.currentPage").description("현재 페이지 번호"), + fieldWithPath("data.challengeList.pageInfoResDto.totalPages").description("총 페이지 수"), + fieldWithPath("data.challengeList.pageInfoResDto.totalItems").description("총 항목 수") + ) + )) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message", is("팀 대시보드와 챌린지 정보 조회"))) + .andExpect(jsonPath("$.data").exists()); + } + +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberRepositoryTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberRepositoryTest.java new file mode 100644 index 00000000..65d46b62 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/domain/repository/MemberRepositoryTest.java @@ -0,0 +1,41 @@ +package shop.kkeujeok.kkeujeokbackend.member.domain.repository; + +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 shop.kkeujeok.kkeujeokbackend.member.domain.Member; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberRepositoryTest { + + @Mock + private MemberRepository memberRepository; + + @DisplayName("email로 member를 찾습니다.") + @Test + void email로_member를_찾습니다() { + // given + String email = "test@example.com"; + Member member = Member.builder() + .email(email) + .build(); + + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(member)); + + // when + Optional foundMember = memberRepository.findByEmail(email); + + // then + assertThat(foundMember).isPresent(); + assertThat(foundMember.get().getEmail()).isEqualTo(email); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/member/mypage/application/MyPageServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/mypage/application/MyPageServiceTest.java new file mode 100644 index 00000000..d81ba853 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/mypage/application/MyPageServiceTest.java @@ -0,0 +1,132 @@ +package shop.kkeujeok.kkeujeokbackend.member.mypage.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import shop.kkeujeok.kkeujeokbackend.challenge.api.dto.response.ChallengeListResDto; +import shop.kkeujeok.kkeujeokbackend.challenge.application.ChallengeService; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.api.dto.response.TeamDashboardListResDto; +import shop.kkeujeok.kkeujeokbackend.dashboard.team.application.TeamDashboardService; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.request.MyPageUpdateReqDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.MyPageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.member.mypage.api.dto.response.TeamDashboardsAndChallengesResDto; + +import java.util.Collections; +import java.util.Optional; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@ExtendWith(MockitoExtension.class) +public class MyPageServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private TeamDashboardService teamDashboardService; + + @Mock + private ChallengeService challengeService; + + @InjectMocks + private MyPageService myPageService; + + private MyPageUpdateReqDto myPageUpdateReqDto; + private MyPageInfoResDto myPageInfoResDto; + private Member member; + private Pageable pageable; + private TeamDashboardListResDto teamDashboardListResDto; + private ChallengeListResDto challengeListResDto; + + @BeforeEach + void setUp() { + myPageUpdateReqDto = new MyPageUpdateReqDto("nickname", "introduction"); + myPageInfoResDto = new MyPageInfoResDto("picture", "email", "name", "nickname", SocialType.GOOGLE, "introduction"); + member = Member.builder() + .email("email") + .name("name") + .nickname("nickname") + .socialType(SocialType.GOOGLE) + .introduction("introduction") + .picture("picture") + .build(); + + pageable = PageRequest.of(0, 10); + + teamDashboardListResDto = TeamDashboardListResDto.of( + Collections.emptyList(), + null + ); + + challengeListResDto = ChallengeListResDto.of( + Collections.emptyList(), + null + ); + } + + @DisplayName("프로필을 조회합니다.") + @Test + void 프로필을_조회합니다() { + // Given + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(member)); + + // When + MyPageInfoResDto result = myPageService.findMyProfileByEmail("email"); + + // Then + assertEquals(myPageInfoResDto.email(), result.email()); + assertEquals(myPageInfoResDto.name(), result.name()); + assertEquals(myPageInfoResDto.nickName(), result.nickName()); + assertEquals(myPageInfoResDto.socialType(), result.socialType()); + assertEquals(myPageInfoResDto.introduction(), result.introduction()); + assertEquals(myPageInfoResDto.picture(), result.picture()); + } + + // 프로필 정보 수정 + @DisplayName("프로필 정보를 수정합니다.") + @Test + void 프로필_정보를_수정합니다() { + // Given + when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(member)); + MyPageUpdateReqDto newMyPageUpdateReqDto = new MyPageUpdateReqDto("newNickname", "newIntroduction"); + // When + MyPageInfoResDto result = myPageService.update("email", newMyPageUpdateReqDto); + + // Then + assertEquals("newNickname", result.nickName()); + assertEquals("newIntroduction", result.introduction()); + + verify(memberRepository, times(1)).findByEmail("email"); + } + + @DisplayName("팀 대시보드와 챌린지 정보를 조회합니다.") + @Test + void 팀_대시보드와_챌린지_정보를_조회합니다() { + // Given + String email = "test@example.com"; + + when(teamDashboardService.findForTeamDashboard(email, pageable)).thenReturn(teamDashboardListResDto); + when(challengeService.findChallengeForMemberId(email, pageable)).thenReturn(challengeListResDto); + + // When + TeamDashboardsAndChallengesResDto result = myPageService.findTeamDashboardsAndChallenges(email, pageable); + + // Then + assertEquals(teamDashboardListResDto, result.teamDashboardList()); + assertEquals(challengeListResDto, result.challengeList()); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/member/nickname/application/NicknameServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/nickname/application/NicknameServiceTest.java new file mode 100644 index 00000000..8008c9ac --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/member/nickname/application/NicknameServiceTest.java @@ -0,0 +1,46 @@ +package shop.kkeujeok.kkeujeokbackend.member.nickname.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Random; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NicknameServiceTest { + + @Mock + private Random random; + + @InjectMocks + private NicknameService nicknameService; + + @BeforeEach + void setUp() { + nicknameService = new NicknameService( + List.of("행복한", "귀여운", "깜찍한"), + List.of("고양이", "인호", "토끼"), + random + ); + } + + @DisplayName("랜덤 닉네임을 생성합니다.") + @Test + void 랜덤_닉네임을_생성합니다() { + when(random.nextInt(anyInt())).thenReturn(0, 1); + + String nickname = nicknameService.getRandomNickname(); + + assertThat(nickname).isEqualTo("행복한인호"); + verify(random, times(2)).nextInt(anyInt()); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/notification/api/NotificationControllerTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/notification/api/NotificationControllerTest.java new file mode 100644 index 00000000..a7097eaa --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/notification/api/NotificationControllerTest.java @@ -0,0 +1,184 @@ +package shop.kkeujeok.kkeujeokbackend.notification.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static shop.kkeujeok.kkeujeokbackend.global.restdocs.RestDocsHandler.responseFields; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import shop.kkeujeok.kkeujeokbackend.auth.api.dto.request.TokenReqDto; +import shop.kkeujeok.kkeujeokbackend.common.annotation.ControllerTest; +import shop.kkeujeok.kkeujeokbackend.global.annotationresolver.CurrentUserEmailArgumentResolver; +import shop.kkeujeok.kkeujeokbackend.global.dto.PageInfoResDto; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.global.error.ControllerAdvice; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.Role; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationInfoResDto; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationListResDto; +import shop.kkeujeok.kkeujeokbackend.notification.application.NotificationService; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; + +@ExtendWith(RestDocumentationExtension.class) +class NotificationControllerTest extends ControllerTest { + + private static final String AUTHORIZATION_HEADER_NAME = "Authorization"; + private static final String AUTHORIZATION_HEADER_VALUE = "Bearer valid-token"; + + private Member member; + private Notification notification; + SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); + + @Mock + private NotificationService notificationService; + + @InjectMocks + private NotificationController notificationController; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + member = Member.builder() + .status(Status.ACTIVE) + .email("kkeujeok@gmail.com") + .name("김동균") + .picture("기본 프로필") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("동동") + .build(); + + notification = Notification.builder() + .receiver(member) + .message("테스트 알림") + .isRead(false) + .build(); + + mockMvc = MockMvcBuilders.standaloneSetup(notificationController) + .apply(documentationConfiguration(restDocumentation)) + .setCustomArgumentResolvers(new CurrentUserEmailArgumentResolver(tokenProvider)) + .setControllerAdvice(new ControllerAdvice()).build(); + + when(tokenProvider.getUserEmailFromToken(any(TokenReqDto.class))).thenReturn("kkeujeok@gmail.com"); + } + + @Test + @DisplayName("사용자는 SSE 발행기를 생성시 상태코드 200 반환.") + void 사용자는_SSE_발행기를_생성시_상태코드_200_반환() throws Exception { + // given + given(notificationService.createEmitter(member.getEmail())).willReturn(emitter); + + // when & then + mockMvc.perform(get("/api/notifications/stream") + .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(member.getEmail())) + .andDo(print()) + .andDo(document("notification/stream", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AUTHORIZATION_HEADER_NAME).description("JWT 토큰") + )) + ) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("알림 전체 조회에 성공하면 상태코드 200 반환") + void 알림_전체_조회에_성공하면_상태코드_200_반환() throws Exception { + // given + NotificationInfoResDto notificationInfoResDto = NotificationInfoResDto.from(notification); + Page notificationPage = new PageImpl<>(List.of(notification), PageRequest.of(0, 10), 1); + NotificationListResDto response = NotificationListResDto.of(List.of(notificationInfoResDto), + PageInfoResDto.from(notificationPage)); + + given(notificationService.findAllNotificationsFromMember(member.getEmail(), PageRequest.of(0, 10))) + .willReturn(response); + + // when & then + mockMvc.perform( + get("/api/notifications") + .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(member.getEmail())) + .andDo(print()) + .andDo(document("notification/findAll", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AUTHORIZATION_HEADER_NAME).description("JWT 토큰")), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.notificationInfoResDto[].id").description("알림 아이디"), + fieldWithPath("data.notificationInfoResDto[].message").description("알림 메시지"), + fieldWithPath("data.notificationInfoResDto[].isRead").description("알림 읽은 여부"), + fieldWithPath("data.pageInfoResDto.currentPage").description("현재 페이지"), + fieldWithPath("data.pageInfoResDto.totalPages").description("전체 페이지"), + fieldWithPath("data.pageInfoResDto.totalItems").description("전체 개수") + )) + ) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("알림 상세 조회에 성공하면 상태코드 200 반환") + void 알림_상세_조회에_성공하면_상태코드_200_반환() throws Exception { + // given + NotificationInfoResDto notificationInfoResDto = NotificationInfoResDto.from(notification); + given(notificationService.findByNotificationId(anyLong())).willReturn(notificationInfoResDto); + + // when & then + mockMvc.perform( + get("/api/notifications/{notificationId}", 1L) + .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) + .accept(MediaType.APPLICATION_JSON).contentType(MediaType.APPLICATION_JSON) + .content(member.getEmail())) + .andDo(print()) + .andDo(document("notification/findById", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("notificationId").description("알림 ID") + ), + responseFields( + fieldWithPath("statusCode").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data.id").description("알림 아이디"), + fieldWithPath("data.message").description("알림 메시지"), + fieldWithPath("data.isRead").description("알림 읽은 여부") + )) + ) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/shop/kkeujeok/kkeujeokbackend/notification/application/NotificationServiceTest.java b/src/test/java/shop/kkeujeok/kkeujeokbackend/notification/application/NotificationServiceTest.java new file mode 100644 index 00000000..f712e115 --- /dev/null +++ b/src/test/java/shop/kkeujeok/kkeujeokbackend/notification/application/NotificationServiceTest.java @@ -0,0 +1,138 @@ +package shop.kkeujeok.kkeujeokbackend.notification.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import shop.kkeujeok.kkeujeokbackend.global.entity.Status; +import shop.kkeujeok.kkeujeokbackend.member.domain.Member; +import shop.kkeujeok.kkeujeokbackend.member.domain.Role; +import shop.kkeujeok.kkeujeokbackend.member.domain.SocialType; +import shop.kkeujeok.kkeujeokbackend.member.domain.repository.MemberRepository; +import shop.kkeujeok.kkeujeokbackend.notification.api.dto.response.NotificationListResDto; +import shop.kkeujeok.kkeujeokbackend.notification.domain.Notification; +import shop.kkeujeok.kkeujeokbackend.notification.domain.repository.NotificationRepository; +import shop.kkeujeok.kkeujeokbackend.notification.exception.NotificationNotFoundException; +import shop.kkeujeok.kkeujeokbackend.notification.util.SseEmitterManager; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private SseEmitter emitter; + + @Mock + private SseEmitterManager sseEmitterManager; + + @InjectMocks + private NotificationService notificationService; + + private Member member; + private Notification notification; + + @BeforeEach + void setUp() { + member = Member.builder() + .status(Status.ACTIVE) + .email("kkeujeok@gmail.com") + .name("김동균") + .picture("기본 프로필") + .socialType(SocialType.GOOGLE) + .role(Role.ROLE_USER) + .firstLogin(true) + .nickname("동동") + .build(); + + notification = Notification.builder() + .receiver(member) + .message("Test Notification") + .isRead(false) + .build(); + + emitter = new SseEmitter(Long.MAX_VALUE); + } + + @Test + @DisplayName("회원은 SSE 발행기를 생성할 수 있다") + void 회원은_SSE_발행기를_생성할_수_있다() { + // given + when(memberRepository.findByEmail(anyString())) + .thenReturn(Optional.of(member)); + + when(sseEmitterManager.createEmitter(member.getId())) + .thenReturn(emitter); + + // when + SseEmitter result = notificationService.createEmitter(member.getEmail()); + + // then + assertThat(result).isNotNull(); + } + + + @Test + @DisplayName("알림을 회원에게 보낼 수 있다") + void 알림을_회원에게_보낼_수_있다() { + // given + when(notificationRepository.save(any(Notification.class))) + .thenReturn(notification); + + // when + notificationService.sendNotification(member, "새로운 알림"); + + // then + // 어떻게 테스트할 지 고민해보겠습니다.. + } + + @Test + @DisplayName("존재하지 않는 알림을 조회하면 예외가 발생한다") + void 존재하지_않는_알림을_조회하면_예외가_발생한다() { + // given + Long nonExistentNotificationId = 999L; + when(notificationRepository.findById(nonExistentNotificationId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> notificationService.findByNotificationId(nonExistentNotificationId)) + .isInstanceOf(NotificationNotFoundException.class); + } + + @Test + @DisplayName("회원의 모든 알림을 조회할 수 있다") + void 회원의_모든_알림을_조회할_수_있다() { + // given + List notifications = List.of(notification); + when(notificationRepository.findAllNotifications(any(Member.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(notifications)); + when(memberRepository.findByEmail(anyString())) + .thenReturn(Optional.of(member)); + + // when + NotificationListResDto result = notificationService.findAllNotificationsFromMember(member.getEmail(), + Pageable.unpaged()); + + // then + assertThat(result.notificationInfoResDto()).isNotEmpty(); + assertThat(result.pageInfoResDto().totalItems()).isEqualTo(notifications.size()); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..03c30d37 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: test